diff --git a/.github/actions/install-frontend-deps/action.yml b/.github/actions/install-frontend-deps/action.yml index 1e6d3e6be80..530ab0a722c 100644 --- a/.github/actions/install-frontend-deps/action.yml +++ b/.github/actions/install-frontend-deps/action.yml @@ -1,5 +1,10 @@ name: install frontend dependencies description: Installs frontend dependencies with pnpm, with caching +inputs: + working-directory: + description: Directory to install dependencies in + required: false + default: invokeai/frontend/web runs: using: 'composite' steps: @@ -30,4 +35,4 @@ runs: - name: install frontend dependencies run: pnpm install --prefer-frozen-lockfile shell: bash - working-directory: invokeai/frontend/web + working-directory: ${{ inputs.working-directory }} diff --git a/.github/workflows/frontend-checks.yml b/.github/workflows/frontend-checks.yml index b36fbeb650b..c287d45529d 100644 --- a/.github/workflows/frontend-checks.yml +++ b/.github/workflows/frontend-checks.yml @@ -30,14 +30,13 @@ on: type: boolean default: true -defaults: - run: - working-directory: invokeai/frontend/web - jobs: frontend-checks: runs-on: ubuntu-latest timeout-minutes: 10 # expected run time: <2 min + defaults: + run: + working-directory: invokeai/frontend/web steps: - uses: actions/checkout@v6 @@ -94,3 +93,37 @@ jobs: if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }} run: 'pnpm lint:knip' shell: bash + + frontend-webv2-checks: + runs-on: ubuntu-latest + timeout-minutes: 10 # expected run time: <2 min + defaults: + run: + working-directory: invokeai/frontend/webv2 + steps: + - uses: actions/checkout@v6 + + - name: check for changed webv2 files + if: ${{ inputs.always_run != true }} + id: changed-files + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 + with: + files_yaml: | + webv2: + - 'invokeai/frontend/webv2/**' + + - name: install dependencies + if: ${{ steps.changed-files.outputs.webv2_any_changed == 'true' || inputs.always_run == true }} + uses: ./.github/actions/install-frontend-deps + with: + working-directory: invokeai/frontend/webv2 + + - name: lint + if: ${{ steps.changed-files.outputs.webv2_any_changed == 'true' || inputs.always_run == true }} + run: 'pnpm run lint' + shell: bash + + - name: build + if: ${{ steps.changed-files.outputs.webv2_any_changed == 'true' || inputs.always_run == true }} + run: 'pnpm run build' + shell: bash diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index abb1fb8419f..fd7df90a256 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -30,14 +30,13 @@ on: type: boolean default: true -defaults: - run: - working-directory: invokeai/frontend/web - jobs: frontend-tests: runs-on: ubuntu-latest timeout-minutes: 10 # expected run time: <2 min + defaults: + run: + working-directory: invokeai/frontend/web steps: - uses: actions/checkout@v6 @@ -63,3 +62,43 @@ jobs: if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }} run: 'pnpm test:no-watch' shell: bash + + frontend-webv2-tests: + runs-on: ubuntu-latest + timeout-minutes: 10 # expected run time: <2 min + defaults: + run: + working-directory: invokeai/frontend/webv2 + steps: + - uses: actions/checkout@v6 + + - name: check for changed webv2 files + if: ${{ inputs.always_run != true }} + id: changed-files + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 + with: + files_yaml: | + webv2: + - '.github/workflows/frontend-tests.yml' + - 'invokeai/frontend/webv2/**' + + - name: install dependencies + if: ${{ steps.changed-files.outputs.webv2_any_changed == 'true' || inputs.always_run == true }} + uses: ./.github/actions/install-frontend-deps + with: + working-directory: invokeai/frontend/webv2 + + - name: vitest + if: ${{ steps.changed-files.outputs.webv2_any_changed == 'true' || inputs.always_run == true }} + run: 'pnpm test' + shell: bash + + - name: install Chromium + if: ${{ steps.changed-files.outputs.webv2_any_changed == 'true' || inputs.always_run == true }} + run: 'pnpm exec playwright install chromium' + shell: bash + + - name: vitest browser + if: ${{ steps.changed-files.outputs.webv2_any_changed == 'true' || inputs.always_run == true }} + run: 'pnpm test:browser' + shell: bash diff --git a/.gitignore b/.gitignore index cc037f09abd..cbb63f66c37 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,16 @@ env/ venv/ ENV/ +# Generated stuff +invokeai.yaml +invokeai.example.yaml + +/models/ +/databases/ +/configs/ +/nodes/ +/outputs/ + # Spyder project settings .spyderproject .spyproject diff --git a/docs/superpowers/specs/2026-07-15-canvas-context-menu-groups-design.md b/docs/superpowers/specs/2026-07-15-canvas-context-menu-groups-design.md new file mode 100644 index 00000000000..b8485c4e75a --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-canvas-context-menu-groups-design.md @@ -0,0 +1,27 @@ +# Canvas Context Menu Groups Design + +## Goal + +Restore the legacy canvas context-menu organization with labeled layer and canvas groups while keeping Delete as the final action. + +## Design + +- Canvas-surface context menus use Chakra `Menu.ItemGroup` and `Menu.ItemGroupLabel` primitives with the established uppercase, subtle label styling. +- A context menu opened over a layer labels the layer-scoped actions with the singular layer type: Raster Layer, Control Layer, Inpaint Mask, or Regional Guidance. +- Canvas-wide actions are labeled Canvas. Save to Gallery remains in this canvas group and appears before the terminal Delete action. +- Delete remains a separated standalone danger action at the bottom so no non-destructive action appears beneath it. +- A context menu opened over empty canvas space shows only the Canvas group. +- Layer-panel kebab and row context menus remain unchanged because the legacy panel menus were ungrouped. + +## Boundaries + +- Reuse existing translations for Canvas and singular layer types. +- Preserve all existing handlers, disabled states, submenu behavior, and menu positioning. +- Add no state, effects, dependencies, or action-registry coupling. + +## Verification + +- Add regression coverage for the surface-menu group model and render order. +- Verify each layer type resolves to the matching legacy label. +- Verify the optional Canvas group precedes the terminal danger section and panel menus remain ungrouped when labels are omitted. +- Run the focused context-menu tests, frontend typecheck, formatting check, and full frontend test suite. diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py index 3092f5ab71a..67e80ff55e3 100644 --- a/invokeai/app/api/dependencies.py +++ b/invokeai/app/api/dependencies.py @@ -42,6 +42,7 @@ from invokeai.app.services.names.names_default import SimpleNameService from invokeai.app.services.object_serializer.object_serializer_disk import ObjectSerializerDisk from invokeai.app.services.object_serializer.object_serializer_forward_cache import ObjectSerializerForwardCache +from invokeai.app.services.project_records.project_records_sqlite import ProjectRecordsSqlite from invokeai.app.services.session_processor.session_processor_default import ( DefaultSessionProcessor, DefaultSessionRunner, @@ -189,6 +190,7 @@ def initialize( style_preset_image_files = StylePresetImageFileStorageDisk(style_presets_folder / "images") workflow_thumbnails = WorkflowThumbnailFileStorageDisk(workflow_thumbnails_folder) client_state_persistence = ClientStatePersistenceSqlite(db=db) + project_records = ProjectRecordsSqlite(db=db) users = UserService(db=db) services = InvocationServices( @@ -223,6 +225,7 @@ def initialize( style_preset_image_files=style_preset_image_files, workflow_thumbnails=workflow_thumbnails, client_state_persistence=client_state_persistence, + project_records=project_records, users=users, ) diff --git a/invokeai/app/api/routers/_access.py b/invokeai/app/api/routers/_access.py index fae3971a144..9e442c6dc29 100644 --- a/invokeai/app/api/routers/_access.py +++ b/invokeai/app/api/routers/_access.py @@ -22,6 +22,8 @@ def assert_image_owner(image_name: str, current_user: CurrentUserOrDefault) -> N """ if current_user.is_admin: return + if not ApiDependencies.invoker.services.image_records.exists(image_name): + raise HTTPException(status_code=404, detail="Image not found") owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name) if owner is not None and owner == current_user.user_id: return @@ -50,6 +52,8 @@ def assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault """ if current_user.is_admin: return + if not ApiDependencies.invoker.services.image_records.exists(image_name): + raise HTTPException(status_code=404, detail="Image not found") owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name) if owner is not None and owner == current_user.user_id: diff --git a/invokeai/app/api/routers/app_info.py b/invokeai/app/api/routers/app_info.py index 832e58f5e24..1ea8cbe494b 100644 --- a/invokeai/app/api/routers/app_info.py +++ b/invokeai/app/api/routers/app_info.py @@ -23,6 +23,7 @@ load_external_api_keys, ) from invokeai.app.services.external_generation.external_generation_common import ExternalProviderStatus +from invokeai.app.services.external_generation.startup import sync_configured_external_starter_models from invokeai.app.services.invocation_cache.invocation_cache_common import InvocationCacheStatus from invokeai.app.services.model_records.model_records_base import UnknownModelException from invokeai.backend.image_util.infill_methods.patchmatch import PatchMatch @@ -178,7 +179,7 @@ async def update_runtime_config( status_code=200, response_model=list[ExternalProviderStatusModel], ) -async def get_external_provider_statuses() -> list[ExternalProviderStatusModel]: +async def get_external_provider_statuses(_: AdminUserOrDefault) -> list[ExternalProviderStatusModel]: statuses = ApiDependencies.invoker.services.external_generation.get_provider_statuses() return [status_to_model(status) for status in statuses.values()] @@ -189,7 +190,7 @@ async def get_external_provider_statuses() -> list[ExternalProviderStatusModel]: status_code=200, response_model=list[ExternalProviderConfigModel], ) -async def get_external_provider_configs() -> list[ExternalProviderConfigModel]: +async def get_external_provider_configs(_: AdminUserOrDefault) -> list[ExternalProviderConfigModel]: config = get_config() return [_build_external_provider_config(provider_id, config) for provider_id in EXTERNAL_PROVIDER_FIELDS] @@ -219,9 +220,14 @@ async def set_external_provider_config( raise HTTPException(status_code=400, detail="No external provider config fields provided") api_key_removed = update.api_key is not None and updates.get(api_key_field) is None + api_key_set = update.api_key is not None and updates.get(api_key_field) is not None _apply_external_provider_update(updates) if api_key_removed: _remove_external_models_for_provider(provider_id) + elif api_key_set: + # Configuring a key should make the provider's models usable without a + # restart; queue its external starter models the same way startup does. + _sync_external_starter_models_for_provider(provider_id) return _build_external_provider_config(provider_id, get_config()) @@ -316,6 +322,19 @@ def _build_external_provider_config(provider_id: str, config: InvokeAIAppConfig) ) +def _sync_external_starter_models_for_provider(provider_id: str) -> None: + invoker = ApiDependencies.invoker + try: + sync_configured_external_starter_models( + configured_provider_ids={provider_id}, + model_manager=invoker.services.model_manager, + logger=invoker.services.logger, + ) + except Exception as error: + # Queuing installs must never fail the config save; surface and move on. + invoker.services.logger.warning(f"Failed queueing external starter models for '{provider_id}': {error}") + + def _remove_external_models_for_provider(provider_id: str) -> None: model_manager = ApiDependencies.invoker.services.model_manager external_models = model_manager.store.search_by_attr( diff --git a/invokeai/app/api/routers/custom_nodes.py b/invokeai/app/api/routers/custom_nodes.py index 3ee8c0ec99c..473d4c55f3b 100644 --- a/invokeai/app/api/routers/custom_nodes.py +++ b/invokeai/app/api/routers/custom_nodes.py @@ -1,8 +1,8 @@ """FastAPI routes for custom node management.""" +import asyncio import json import shutil -import subprocess import sys import traceback from importlib.util import module_from_spec, spec_from_file_location @@ -28,6 +28,7 @@ # were imported by that pack. Used on uninstall to delete only pack-imported workflows # — deleting by tag alone is unsafe because users can edit tags on their own workflows. PACK_MANIFEST_FILENAME = ".invokeai_pack_manifest.json" +GIT_CLONE_TIMEOUT_SECONDS = 120 class NodePackInfo(BaseModel): @@ -126,6 +127,26 @@ def _get_installed_packs() -> list[NodePackInfo]: return packs +async def _clone_node_pack(source: str, target_dir: Path) -> tuple[int, str]: + process = await asyncio.create_subprocess_exec( + "git", + "clone", + source, + str(target_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + try: + _stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=GIT_CLONE_TIMEOUT_SECONDS) + except asyncio.TimeoutError: + process.kill() + await process.communicate() + raise + + return process.returncode or 0, stderr.decode(errors="replace").strip() + + @custom_nodes_router.get( "/", operation_id="list_custom_node_packs", @@ -172,21 +193,16 @@ async def install_custom_node_pack( try: # Clone the repository - result = subprocess.run( - ["git", "clone", source, str(target_dir)], - capture_output=True, - text=True, - timeout=120, - ) + returncode, stderr = await _clone_node_pack(source, target_dir) - if result.returncode != 0: + if returncode != 0: # Clean up on failure if target_dir.exists(): shutil.rmtree(target_dir) return InstallNodePackResponse( name=pack_name, success=False, - message=f"Git clone failed: {result.stderr.strip()}", + message=f"Git clone failed: {stderr}", ) # Detect dependency manifests but do NOT install them automatically. @@ -232,7 +248,7 @@ async def install_custom_node_pack( dependency_file=dependency_file, ) - except subprocess.TimeoutExpired: + except asyncio.TimeoutError: if target_dir.exists(): shutil.rmtree(target_dir) return InstallNodePackResponse( diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index bdd2e406444..a1c47b4e722 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -11,7 +11,7 @@ from typing import List, Optional, Type import huggingface_hub -from fastapi import Body, Path, Query, Response, UploadFile +from fastapi import Body, Header, Path, Query, Response, UploadFile from fastapi.responses import FileResponse, HTMLResponse from fastapi.routing import APIRouter from PIL import Image @@ -68,6 +68,11 @@ class ModelsList(BaseModel): model_config = ConfigDict(use_enum_values=True) +class EmptyModelCacheResponse(BaseModel): + models_cleared: int + bytes_freed: int + + class CacheType(str, Enum): """Cache type - one of vram or ram.""" @@ -218,6 +223,20 @@ async def list_missing_models() -> ModelsList: return ModelsList(models=missing_models) +@model_manager_router.get( + "/models_dir", + operation_id="get_models_dir", + responses={200: {"description": "The absolute path of the models directory"}}, +) +async def get_models_dir() -> str: + """Get the absolute path of the directory managed models are stored in. + + Model config `path` values are relative to this directory unless they are + absolute (in-place installs from outside it). + """ + return ApiDependencies.invoker.services.configuration.models_path.resolve().as_posix() + + @model_manager_router.get( "/get_by_attrs", operation_id="get_model_records_by_attrs", @@ -747,6 +766,11 @@ async def install_model( source: str = Query(description="Model source to install, can be a local path, repo_id, or remote URL"), inplace: Optional[bool] = Query(description="Whether or not to install a local model in place", default=False), access_token: Optional[str] = Query(description="access token for the remote resource", default=None), + source_access_token: Optional[str] = Header( + alias="X-Model-Source-Access-Token", + description="access token for the remote resource", + default=None, + ), config: ModelRecordChanges = Body( description="Object containing fields that override auto-probed values in the model config record, such as name, description and prediction_type ", examples=[{"name": "string", "description": "string"}], @@ -786,7 +810,7 @@ async def install_model( result: ModelInstallJob = installer.heuristic_import( source=source, config=config, - access_token=access_token, + access_token=source_access_token or access_token, inplace=bool(inplace), ) logger.info(f"Started installation of {source}") @@ -1301,12 +1325,14 @@ async def get_stats() -> Optional[CacheStats]: "/empty_model_cache", operation_id="empty_model_cache", status_code=200, + response_model=EmptyModelCacheResponse, ) -async def empty_model_cache(current_admin: AdminUserOrDefault) -> None: +async def empty_model_cache(current_admin: AdminUserOrDefault) -> EmptyModelCacheResponse: """Drop all models from the model cache to free RAM/VRAM. 'Locked' models that are in active use will not be dropped.""" # Request 1000GB of room in order to force the cache to drop all models. ApiDependencies.invoker.services.logger.info("Emptying model cache.") - ApiDependencies.invoker.services.model_manager.load.ram_cache.make_room(1000 * 2**30) + result = ApiDependencies.invoker.services.model_manager.load.ram_cache.make_room(1000 * 2**30) + return EmptyModelCacheResponse(models_cleared=result.models_cleared, bytes_freed=result.bytes_freed) class HFTokenStatus(str, Enum): @@ -1341,7 +1367,7 @@ def reset_token(cls) -> HFTokenStatus: @model_manager_router.get("/hf_login", operation_id="get_hf_login_status", response_model=HFTokenStatus) -async def get_hf_login_status() -> HFTokenStatus: +async def get_hf_login_status(_: AdminUserOrDefault) -> HFTokenStatus: token_status = HFTokenHelper.get_status() if token_status is HFTokenStatus.UNKNOWN: diff --git a/invokeai/app/api/routers/projects.py b/invokeai/app/api/routers/projects.py new file mode 100644 index 00000000000..6609c091235 --- /dev/null +++ b/invokeai/app/api/routers/projects.py @@ -0,0 +1,105 @@ +from typing import Any + +from fastapi import Body, HTTPException, Path, status +from fastapi.routing import APIRouter +from pydantic import BaseModel, Field + +from invokeai.app.api.auth_dependencies import CurrentUserOrDefault +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.services.project_records.project_records_common import ( + ProjectRecordConflictError, + ProjectRecordDTO, + ProjectRecordExistsError, + ProjectRecordNotFoundError, + ProjectSummaryDTO, +) + +projects_router = APIRouter(prefix="/v1/projects", tags=["projects"]) + + +class ProjectCreateRequest(BaseModel): + """Request body for creating a project.""" + + project_id: str | None = Field( + default=None, description="Client-generated project id (e.g. for imports); generated when omitted" + ) + name: str = Field(description="The project's display name") + data: dict[str, Any] = Field(description="The opaque client-owned project document") + + +class ProjectUpdateRequest(BaseModel): + """Request body for saving a project with optimistic concurrency.""" + + name: str = Field(description="The project's display name") + data: dict[str, Any] = Field(description="The opaque client-owned project document") + expected_revision: int = Field(description="The revision this save is based on; mismatch returns 409") + + +@projects_router.get("/", operation_id="list_projects", response_model=list[ProjectSummaryDTO]) +async def list_projects(current_user: CurrentUserOrDefault) -> list[ProjectSummaryDTO]: + """Lists the current user's projects as lightweight summaries (no documents).""" + return ApiDependencies.invoker.services.project_records.list(current_user.user_id) + + +@projects_router.post( + "/", operation_id="create_project", response_model=ProjectRecordDTO, status_code=status.HTTP_201_CREATED +) +async def create_project( + current_user: CurrentUserOrDefault, + request: ProjectCreateRequest = Body(description="The project to create"), +) -> ProjectRecordDTO: + """Creates a project for the current user.""" + try: + return ApiDependencies.invoker.services.project_records.create( + user_id=current_user.user_id, + name=request.name, + data=request.data, + project_id=request.project_id, + ) + except ProjectRecordExistsError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + + +@projects_router.get("/{project_id}", operation_id="get_project", response_model=ProjectRecordDTO) +async def get_project( + current_user: CurrentUserOrDefault, + project_id: str = Path(description="The id of the project to get"), +) -> ProjectRecordDTO: + """Gets one of the current user's projects, including its document.""" + try: + return ApiDependencies.invoker.services.project_records.get(current_user.user_id, project_id) + except ProjectRecordNotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + +@projects_router.put("/{project_id}", operation_id="update_project", response_model=ProjectRecordDTO) +async def update_project( + current_user: CurrentUserOrDefault, + project_id: str = Path(description="The id of the project to save"), + request: ProjectUpdateRequest = Body(description="The project document and the revision it is based on"), +) -> ProjectRecordDTO: + """Saves a project. Returns 409 with the current revision when the save is based on a stale revision.""" + try: + return ApiDependencies.invoker.services.project_records.update( + user_id=current_user.user_id, + project_id=project_id, + expected_revision=request.expected_revision, + name=request.name, + data=request.data, + ) + except ProjectRecordNotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except ProjectRecordConflictError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"message": str(e), "current_revision": e.current_revision}, + ) + + +@projects_router.delete("/{project_id}", operation_id="delete_project", status_code=status.HTTP_204_NO_CONTENT) +async def delete_project( + current_user: CurrentUserOrDefault, + project_id: str = Path(description="The id of the project to delete"), +) -> None: + """Deletes one of the current user's projects. Idempotent.""" + ApiDependencies.invoker.services.project_records.delete(current_user.user_id, project_id) diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index 89d2f9a7395..5e6dffce337 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Callable, Optional from fastapi import Body, HTTPException, Path, Query from fastapi.routing import APIRouter @@ -7,6 +7,7 @@ from invokeai.app.api.auth_dependencies import AdminUserOrDefault, CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive +from invokeai.app.invocations.fields import ImageField from invokeai.app.services.session_processor.session_processor_common import SessionProcessorStatus from invokeai.app.services.session_queue.session_queue_common import ( Batch, @@ -39,6 +40,59 @@ class SessionQueueAndProcessorStatus(BaseModel): processor: SessionProcessorStatus +def _image_record_exists(image_name: str) -> bool: + return ApiDependencies.invoker.services.image_records.exists(image_name) + + +def strip_missing_image_results( + queue_item: SessionQueueItem, image_exists: Callable[[str], bool] | None = None +) -> SessionQueueItem: + """Remove result outputs whose image records have been deleted. + + Completed queue history can outlive its output images. API clients hydrate + images listed in `session.results`; returning stale names makes them loop on + 404s. Keep the queue item/history, but do not advertise impossible outputs. + """ + if not queue_item.session.results: + return queue_item + + image_exists = image_exists or _image_record_exists + filtered_results = {} + did_filter = False + exists_cache: dict[str, bool] = {} + + def cached_exists(image_name: str) -> bool: + if image_name not in exists_cache: + exists_cache[image_name] = image_exists(image_name) + return exists_cache[image_name] + + for node_id, output in queue_item.session.results.items(): + image = getattr(output, "image", None) + if isinstance(image, ImageField) and not cached_exists(image.image_name): + did_filter = True + continue + + collection = getattr(output, "collection", None) + if isinstance(collection, list) and any(isinstance(item, ImageField) for item in collection): + filtered_collection = [ + item for item in collection if not isinstance(item, ImageField) or cached_exists(item.image_name) + ] + if len(filtered_collection) != len(collection): + did_filter = True + if len(filtered_collection) == 0: + continue + output = output.model_copy(update={"collection": filtered_collection}) + + filtered_results[node_id] = output + + if not did_filter: + return queue_item + + sanitized_item = queue_item.model_copy(deep=True) + sanitized_item.session.results = filtered_results + return sanitized_item + + def _get_workflow_call_root_queue_item(queue_item: SessionQueueItem) -> SessionQueueItem: if queue_item.root_item_id is None: return queue_item @@ -64,7 +118,7 @@ def sanitize_queue_item_for_user( """ # Admins and item owners can see everything if is_admin or queue_item.user_id == current_user_id: - return queue_item + return strip_missing_image_results(queue_item) # For non-admins viewing other users' items, strip everything except # item_id, queue_id, status, and timestamps @@ -95,6 +149,18 @@ def sanitize_queue_item_for_user( return sanitized_item +def get_queue_item_for_mutation(queue_id: str, item_id: int, current_user: CurrentUserOrDefault) -> SessionQueueItem: + queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id) + + if queue_item.queue_id != queue_id: + raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}") + + if queue_item.user_id != current_user.user_id and not current_user.is_admin: + raise HTTPException(status_code=403, detail=f"You do not have permission to mutate queue item {item_id}") + + return queue_item + + @session_queue_router.post( "/{queue_id}/enqueue_batch", operation_id="enqueue_batch", @@ -154,6 +220,9 @@ async def get_queue_item_ids( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), + origin_prefix: Optional[str] = Query( + default=None, description="Only include queue items whose origin starts with this prefix" + ), ) -> ItemIdsResult: """Gets all queue item ids that match the given parameters. @@ -166,7 +235,9 @@ async def get_queue_item_ids( current_user is required so the endpoint stays behind authentication in multiuser mode. """ try: - return ApiDependencies.invoker.services.session_queue.get_queue_item_ids(queue_id=queue_id, order_dir=order_dir) + return ApiDependencies.invoker.services.session_queue.get_queue_item_ids( + queue_id=queue_id, order_dir=order_dir, origin_prefix=origin_prefix + ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while listing all queue item ids: {e}") @@ -425,10 +496,13 @@ async def prune( async def get_current_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), + origin_prefix: Optional[str] = Query( + default=None, description="Only include queue items whose origin starts with this prefix" + ), ) -> Optional[SessionQueueItem]: """Gets the currently execution queue item""" try: - item = ApiDependencies.invoker.services.session_queue.get_current(queue_id) + item = ApiDependencies.invoker.services.session_queue.get_current(queue_id, origin_prefix=origin_prefix) if item is not None: item = sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin) return item @@ -446,10 +520,13 @@ async def get_current_queue_item( async def get_next_queue_item( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), + origin_prefix: Optional[str] = Query( + default=None, description="Only include queue items whose origin starts with this prefix" + ), ) -> Optional[SessionQueueItem]: """Gets the next queue item, without executing it""" try: - item = ApiDependencies.invoker.services.session_queue.get_next(queue_id) + item = ApiDependencies.invoker.services.session_queue.get_next(queue_id, origin_prefix=origin_prefix) if item is not None: item = sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin) return item @@ -467,13 +544,18 @@ async def get_next_queue_item( async def get_queue_status( current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), + origin_prefix: Optional[str] = Query( + default=None, description="Only include queue items whose origin starts with this prefix" + ), ) -> SessionQueueAndProcessorStatus: """Gets the status of the session queue. Returns global counts; non-admin users additionally get their own pending/in_progress counts (so the UI can show an X/Y badge) and cannot see the current item's identifiers unless they own it.""" try: user_id = None if current_user.is_admin else current_user.user_id - queue = ApiDependencies.invoker.services.session_queue.get_queue_status(queue_id, user_id=user_id) + queue = ApiDependencies.invoker.services.session_queue.get_queue_status( + queue_id, user_id=user_id, origin_prefix=origin_prefix + ) processor = ApiDependencies.invoker.services.session_processor.get_status() return SessionQueueAndProcessorStatus(queue=queue, processor=processor) except Exception as e: @@ -575,14 +657,7 @@ async def cancel_queue_item( ) -> SessionQueueItem: """Cancels a queue item. Users can only cancel their own items unless they are an admin.""" try: - # Get the queue item to check ownership - queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id) - if queue_item.queue_id != queue_id: - raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}") - - # Check authorization: user must own the item or be an admin - if queue_item.user_id != current_user.user_id and not current_user.is_admin: - raise HTTPException(status_code=403, detail="You do not have permission to cancel this queue item") + get_queue_item_for_mutation(queue_id, item_id, current_user) return ApiDependencies.invoker.services.session_queue.cancel_queue_item(item_id) except SessionQueueItemNotFoundError: diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py index bdaf641c1c1..19392a107bd 100644 --- a/invokeai/app/api/sockets.py +++ b/invokeai/app/api/sockets.py @@ -29,6 +29,7 @@ ModelInstallCompleteEvent, ModelInstallDownloadProgressEvent, ModelInstallDownloadsCompleteEvent, + ModelInstallDownloadStartedEvent, ModelInstallErrorEvent, ModelInstallStartedEvent, ModelLoadCompleteEvent, @@ -84,6 +85,7 @@ class BulkDownloadSubscriptionEvent(BaseModel): DownloadStartedEvent, ModelLoadStartedEvent, ModelLoadCompleteEvent, + ModelInstallDownloadStartedEvent, ModelInstallDownloadProgressEvent, ModelInstallDownloadsCompleteEvent, ModelInstallStartedEvent, @@ -95,6 +97,16 @@ class BulkDownloadSubscriptionEvent(BaseModel): BULK_DOWNLOAD_EVENTS = {BulkDownloadStartedEvent, BulkDownloadCompleteEvent, BulkDownloadErrorEvent} WORKFLOW_EVENTS = {WorkflowCreatedEvent, WorkflowUpdatedEvent, WorkflowDeletedEvent} +MODEL_INSTALL_EVENTS = ( + ModelInstallDownloadStartedEvent, + ModelInstallDownloadProgressEvent, + ModelInstallDownloadsCompleteEvent, + ModelInstallStartedEvent, + ModelInstallCompleteEvent, + ModelInstallCancelledEvent, + ModelInstallErrorEvent, +) + class SocketIO: _sub_queue = "subscribe_queue" @@ -490,7 +502,12 @@ async def _handle_queue_event(self, event: FastAPIEvent[QueueEventBase]): logger.error(f"Error handling queue event {event[0]}: {e}", exc_info=True) async def _handle_model_event(self, event: FastAPIEvent[ModelEventBase | DownloadEventBase]) -> None: - await self._sio.emit(event=event[0], data=event[1].model_dump(mode="json")) + event_name, event_data = event + if isinstance(event_data, MODEL_INSTALL_EVENTS): + await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin") + return + + await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json")) async def _handle_bulk_image_download_event(self, event: FastAPIEvent[BulkDownloadEventBase]) -> None: event_name, event_data = event diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index ed02d75eae3..e56f2bdb91b 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -27,6 +27,7 @@ images, model_manager, model_relationships, + projects, recall_parameters, session_queue, style_presets, @@ -188,6 +189,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): app.include_router(workflows.workflows_router, prefix="/api") app.include_router(style_presets.style_presets_router, prefix="/api") app.include_router(client_state.client_state_router, prefix="/api") +app.include_router(projects.projects_router, prefix="/api") app.include_router(recall_parameters.recall_parameters_router, prefix="/api") app.include_router(custom_nodes.custom_nodes_router, prefix="/api") diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py index 8c71dfba9e7..9e263522cd9 100644 --- a/invokeai/app/services/image_records/image_records_base.py +++ b/invokeai/app/services/image_records/image_records_base.py @@ -30,6 +30,11 @@ def get_metadata(self, image_name: str) -> Optional[MetadataField]: """Gets an image's metadata'.""" pass + @abstractmethod + def exists(self, image_name: str) -> bool: + """Returns whether an image record exists.""" + pass + @abstractmethod def update( self, diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py index 1eb3857dba6..8a6a7f47c68 100644 --- a/invokeai/app/services/image_records/image_records_sqlite.py +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -84,6 +84,18 @@ def get_metadata(self, image_name: str) -> Optional[MetadataField]: metadata_raw = cast(Optional[str], as_dict.get("metadata", None)) return MetadataFieldValidator.validate_json(metadata_raw) if metadata_raw is not None else None + def exists(self, image_name: str) -> bool: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT 1 FROM images + WHERE image_name = ? + LIMIT 1; + """, + (image_name,), + ) + return cursor.fetchone() is not None + def update( self, image_name: str, diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py index 4a190f37edc..dabc65fb9f6 100644 --- a/invokeai/app/services/images/images_default.py +++ b/invokeai/app/services/images/images_default.py @@ -132,6 +132,9 @@ def get_pil_image(self, image_name: str) -> PILImageType: try: record = self.__invoker.services.image_records.get(image_name) return self.__invoker.services.image_files.get(image_name, image_subfolder=record.image_subfolder) + except ImageRecordNotFoundException: + self.__invoker.services.logger.debug(f"Image record not found: {image_name}") + raise except ImageFileNotFoundException: self.__invoker.services.logger.error("Failed to get image file") raise @@ -143,7 +146,7 @@ def get_record(self, image_name: str) -> ImageRecord: try: return self.__invoker.services.image_records.get(image_name) except ImageRecordNotFoundException: - self.__invoker.services.logger.error("Image record not found") + self.__invoker.services.logger.debug(f"Image record not found: {image_name}") raise except Exception as e: self.__invoker.services.logger.error("Problem getting image record") @@ -162,7 +165,7 @@ def get_dto(self, image_name: str) -> ImageDTO: return image_dto except ImageRecordNotFoundException: - self.__invoker.services.logger.error("Image record not found") + self.__invoker.services.logger.debug(f"Image record not found: {image_name}") raise except Exception as e: self.__invoker.services.logger.error("Problem getting image DTO") @@ -172,7 +175,7 @@ def get_metadata(self, image_name: str) -> Optional[MetadataField]: try: return self.__invoker.services.image_records.get_metadata(image_name) except ImageRecordNotFoundException: - self.__invoker.services.logger.error("Image record not found") + self.__invoker.services.logger.debug(f"Image record not found: {image_name}") raise except Exception as e: self.__invoker.services.logger.error("Problem getting image metadata") @@ -182,6 +185,9 @@ def get_workflow(self, image_name: str) -> Optional[str]: try: record = self.__invoker.services.image_records.get(image_name) return self.__invoker.services.image_files.get_workflow(image_name, image_subfolder=record.image_subfolder) + except ImageRecordNotFoundException: + self.__invoker.services.logger.debug(f"Image record not found: {image_name}") + raise except ImageFileNotFoundException: self.__invoker.services.logger.error("Image file not found") raise @@ -193,6 +199,9 @@ def get_graph(self, image_name: str) -> Optional[str]: try: record = self.__invoker.services.image_records.get(image_name) return self.__invoker.services.image_files.get_graph(image_name, image_subfolder=record.image_subfolder) + except ImageRecordNotFoundException: + self.__invoker.services.logger.debug(f"Image record not found: {image_name}") + raise except ImageFileNotFoundException: self.__invoker.services.logger.error("Image file not found") raise @@ -208,6 +217,9 @@ def get_path(self, image_name: str, thumbnail: bool = False) -> str: image_name, thumbnail, image_subfolder=record.image_subfolder ) ) + except ImageRecordNotFoundException: + self.__invoker.services.logger.debug(f"Image record not found: {image_name}") + raise except Exception as e: self.__invoker.services.logger.error("Problem getting image path") raise e diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py index 54f9d82b786..d46289a65e5 100644 --- a/invokeai/app/services/invocation_services.py +++ b/invokeai/app/services/invocation_services.py @@ -35,6 +35,7 @@ ) from invokeai.app.services.model_relationships.model_relationships_base import ModelRelationshipsServiceABC from invokeai.app.services.names.names_base import NameServiceBase + from invokeai.app.services.project_records.project_records_base import ProjectRecordsStorageBase from invokeai.app.services.session_processor.session_processor_base import SessionProcessorBase from invokeai.app.services.session_queue.session_queue_base import SessionQueueBase from invokeai.app.services.urls.urls_base import UrlServiceBase @@ -79,6 +80,7 @@ def __init__( style_preset_image_files: "StylePresetImageFileStorageBase", workflow_thumbnails: "WorkflowThumbnailServiceBase", client_state_persistence: "ClientStatePersistenceABC", + project_records: "ProjectRecordsStorageBase", users: "UserServiceBase", image_moves: "ImageMoveService | None" = None, ): @@ -113,4 +115,5 @@ def __init__( self.style_preset_image_files = style_preset_image_files self.workflow_thumbnails = workflow_thumbnails self.client_state_persistence = client_state_persistence + self.project_records = project_records self.users = users diff --git a/invokeai/app/services/project_records/project_records_base.py b/invokeai/app/services/project_records/project_records_base.py new file mode 100644 index 00000000000..0bc02b74d70 --- /dev/null +++ b/invokeai/app/services/project_records/project_records_base.py @@ -0,0 +1,62 @@ +from abc import ABC, abstractmethod +from typing import Any + +from invokeai.app.services.project_records.project_records_common import ProjectRecordDTO, ProjectSummaryDTO + + +class ProjectRecordsStorageBase(ABC): + """Storage for per-user workbench project documents. + + All operations are scoped by user_id; a user can never read or write + another user's projects. Saves use optimistic concurrency via the + project's monotonic revision. + """ + + @abstractmethod + def create(self, user_id: str, name: str, data: dict[str, Any], project_id: str | None = None) -> ProjectRecordDTO: + """Create a project for the user. + + Args: + user_id: The owning user. + name: The project's display name. + data: The opaque client-owned project document. + project_id: Client-generated id (e.g. for imports); generated when omitted. + + Returns: + The created project record. + + Raises: + ProjectRecordExistsError: The user already has a project with this id. + """ + pass + + @abstractmethod + def get(self, user_id: str, project_id: str) -> ProjectRecordDTO: + """Get one of the user's projects, including its document. + + Raises: + ProjectRecordNotFoundError: No such project for this user. + """ + pass + + @abstractmethod + def list(self, user_id: str) -> list[ProjectSummaryDTO]: + """List the user's projects as lightweight summaries, oldest first.""" + pass + + @abstractmethod + def update( + self, user_id: str, project_id: str, expected_revision: int, name: str, data: dict[str, Any] + ) -> ProjectRecordDTO: + """Save a project if the caller's revision is current. + + Raises: + ProjectRecordNotFoundError: No such project for this user. + ProjectRecordConflictError: The stored revision differs from expected_revision. + """ + pass + + @abstractmethod + def delete(self, user_id: str, project_id: str) -> None: + """Delete one of the user's projects. Idempotent: deleting a missing project is a no-op.""" + pass diff --git a/invokeai/app/services/project_records/project_records_common.py b/invokeai/app/services/project_records/project_records_common.py new file mode 100644 index 00000000000..dc25b170174 --- /dev/null +++ b/invokeai/app/services/project_records/project_records_common.py @@ -0,0 +1,45 @@ +"""Common types and errors for the project records service.""" + +from typing import Any + +from pydantic import BaseModel, Field + + +class ProjectRecordNotFoundError(Exception): + """Raised when a project record is not found for the requesting user.""" + + def __init__(self, project_id: str) -> None: + super().__init__(f"Project {project_id} not found") + + +class ProjectRecordExistsError(Exception): + """Raised when creating a project with an id the user already has.""" + + def __init__(self, project_id: str) -> None: + super().__init__(f"Project {project_id} already exists") + + +class ProjectRecordConflictError(Exception): + """Raised when a save carries a stale revision (another client saved first).""" + + def __init__(self, project_id: str, expected_revision: int, current_revision: int) -> None: + self.current_revision = current_revision + super().__init__( + f"Project {project_id} is at revision {current_revision}; the save expected revision {expected_revision}" + ) + + +class ProjectSummaryDTO(BaseModel): + """Lightweight project listing entry; the document payload is omitted.""" + + project_id: str = Field(description="The project's client-generated identifier") + name: str = Field(description="The project's display name") + revision: int = Field(description="Monotonic revision, incremented on every save") + created_at: str = Field(description="When the project was created") + updated_at: str = Field(description="When the project was last saved") + + +class ProjectRecordDTO(ProjectSummaryDTO): + """Full project record including the client-owned document.""" + + data: dict[str, Any] = Field(description="The opaque client-owned project document") diff --git a/invokeai/app/services/project_records/project_records_sqlite.py b/invokeai/app/services/project_records/project_records_sqlite.py new file mode 100644 index 00000000000..0369a626f45 --- /dev/null +++ b/invokeai/app/services/project_records/project_records_sqlite.py @@ -0,0 +1,134 @@ +import json +import sqlite3 +import uuid +from typing import Any + +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.project_records.project_records_base import ProjectRecordsStorageBase +from invokeai.app.services.project_records.project_records_common import ( + ProjectRecordConflictError, + ProjectRecordDTO, + ProjectRecordExistsError, + ProjectRecordNotFoundError, + ProjectSummaryDTO, +) +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase + + +class ProjectRecordsSqlite(ProjectRecordsStorageBase): + """SQLite implementation of per-user project document storage.""" + + def __init__(self, db: SqliteDatabase) -> None: + super().__init__() + self._db = db + + def start(self, invoker: Invoker) -> None: + self._invoker = invoker + + def create(self, user_id: str, name: str, data: dict[str, Any], project_id: str | None = None) -> ProjectRecordDTO: + project_id = project_id or uuid.uuid4().hex + + try: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + INSERT INTO projects (project_id, user_id, name, data) + VALUES (?, ?, ?, ?); + """, + (project_id, user_id, name, json.dumps(data)), + ) + except sqlite3.IntegrityError as e: + raise ProjectRecordExistsError(project_id) from e + + return self.get(user_id, project_id) + + def get(self, user_id: str, project_id: str) -> ProjectRecordDTO: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT project_id, name, data, revision, created_at, updated_at + FROM projects + WHERE user_id = ? AND project_id = ?; + """, + (user_id, project_id), + ) + row = cursor.fetchone() + + if row is None: + raise ProjectRecordNotFoundError(project_id) + + return ProjectRecordDTO( + project_id=row[0], + name=row[1], + data=json.loads(row[2]), + revision=row[3], + created_at=row[4], + updated_at=row[5], + ) + + def list(self, user_id: str) -> list[ProjectSummaryDTO]: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT project_id, name, revision, created_at, updated_at + FROM projects + WHERE user_id = ? + -- rowid breaks ties between rows created in the same millisecond, + -- keeping the listing in true insertion order. + ORDER BY created_at ASC, rowid ASC; + """, + (user_id,), + ) + rows = cursor.fetchall() + + return [ + ProjectSummaryDTO( + project_id=row[0], + name=row[1], + revision=row[2], + created_at=row[3], + updated_at=row[4], + ) + for row in rows + ] + + def update( + self, user_id: str, project_id: str, expected_revision: int, name: str, data: dict[str, Any] + ) -> ProjectRecordDTO: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + UPDATE projects + SET name = ?, data = ?, revision = revision + 1 + WHERE user_id = ? AND project_id = ? AND revision = ?; + """, + (name, json.dumps(data), user_id, project_id, expected_revision), + ) + + if cursor.rowcount == 0: + # Distinguish "gone" from "someone else saved first". + cursor.execute( + """--sql + SELECT revision FROM projects + WHERE user_id = ? AND project_id = ?; + """, + (user_id, project_id), + ) + row = cursor.fetchone() + + if row is None: + raise ProjectRecordNotFoundError(project_id) + + raise ProjectRecordConflictError(project_id, expected_revision, row[0]) + + return self.get(user_id, project_id) + + def delete(self, user_id: str, project_id: str) -> None: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + DELETE FROM projects + WHERE user_id = ? AND project_id = ?; + """, + (user_id, project_id), + ) diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index a07abf10ee1..a3882524fbb 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -44,12 +44,12 @@ def enqueue_batch( pass @abstractmethod - def get_current(self, queue_id: str) -> Optional[SessionQueueItem]: + def get_current(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: """Gets the currently-executing session queue item""" pass @abstractmethod - def get_next(self, queue_id: str) -> Optional[SessionQueueItem]: + def get_next(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: """Gets the next session queue item (does not dequeue it)""" pass @@ -79,6 +79,7 @@ def get_queue_status( queue_id: str, user_id: Optional[str] = None, acting_user_id: Optional[str] = None, + origin_prefix: Optional[str] = None, ) -> SessionQueueStatus: """Gets the status of the queue. @@ -207,6 +208,7 @@ def get_queue_item_ids( queue_id: str, order_dir: SQLiteDirection = SQLiteDirection.Descending, user_id: Optional[str] = None, + origin_prefix: Optional[str] = None, ) -> ItemIdsResult: """Gets all queue item ids that match the given parameters. If user_id is provided, only returns items for that user.""" pass diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index 51980d68f3b..9f4f82817f7 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -279,7 +279,7 @@ async def enqueue_batch( SELECT item_id FROM session_queue WHERE batch_id = ? - ORDER BY item_id DESC; + ORDER BY item_id ASC; """, (batch.batch_id,), ) @@ -310,10 +310,9 @@ def dequeue(self) -> Optional[SessionQueueItem]: queue_item = self._set_queue_item_status(item_id=queue_item.item_id, status="in_progress") return queue_item - def get_next(self, queue_id: str) -> Optional[SessionQueueItem]: + def get_next(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: with self._db.transaction() as cursor: - cursor.execute( - """--sql + query = """--sql SELECT sq.*, u.display_name as user_display_name, @@ -323,22 +322,28 @@ def get_next(self, queue_id: str) -> Optional[SessionQueueItem]: WHERE sq.queue_id = ? AND sq.status = 'pending' + """ + params = [queue_id] + if origin_prefix is not None: + query += """--sql + AND sq.origin LIKE ? + """ + params.append(f"{origin_prefix}%") + query += """--sql ORDER BY sq.priority DESC, sq.created_at ASC LIMIT 1 - """, - (queue_id,), - ) + """ + cursor.execute(query, params) result = cast(Union[sqlite3.Row, None], cursor.fetchone()) if result is None: return None return SessionQueueItem.queue_item_from_dict(dict(result)) - def get_current(self, queue_id: str) -> Optional[SessionQueueItem]: + def get_current(self, queue_id: str, origin_prefix: Optional[str] = None) -> Optional[SessionQueueItem]: with self._db.transaction() as cursor: - cursor.execute( - """--sql + query = """--sql SELECT sq.*, u.display_name as user_display_name, @@ -348,10 +353,17 @@ def get_current(self, queue_id: str) -> Optional[SessionQueueItem]: WHERE sq.queue_id = ? AND sq.status = 'in_progress' + """ + params = [queue_id] + if origin_prefix is not None: + query += """--sql + AND sq.origin LIKE ? + """ + params.append(f"{origin_prefix}%") + query += """--sql LIMIT 1 - """, - (queue_id,), - ) + """ + cursor.execute(query, params) result = cast(Union[sqlite3.Row, None], cursor.fetchone()) if result is None: return None @@ -1117,6 +1129,7 @@ def get_queue_item_ids( queue_id: str, order_dir: SQLiteDirection = SQLiteDirection.Descending, user_id: Optional[str] = None, + origin_prefix: Optional[str] = None, ) -> ItemIdsResult: with self._db.transaction() as cursor_: query = """--sql @@ -1130,6 +1143,10 @@ def get_queue_item_ids( query += " AND user_id = ?" query_params.append(user_id) + if origin_prefix is not None: + query += " AND origin LIKE ?" + query_params.append(f"{origin_prefix}%") + query += f" ORDER BY created_at {order_dir.value}" cursor_.execute(query, query_params) @@ -1143,20 +1160,23 @@ def get_queue_status( queue_id: str, user_id: Optional[str] = None, acting_user_id: Optional[str] = None, + origin_prefix: Optional[str] = None, ) -> SessionQueueStatus: with self._db.transaction() as cursor: - # Aggregate counts are always global (across all users). This lets a non-admin's - # badge show "own / total" — their share of the whole queue — and lets the queue - # list surface (redacted) entries belonging to other users. - cursor.execute( - """--sql + # Aggregate counts are global across all users within the requested scope. + query = """--sql SELECT status, count(*) FROM session_queue WHERE queue_id = ? - GROUP BY status - """, - (queue_id,), - ) + """ + params: list[str] = [queue_id] + + if origin_prefix is not None: + query += " AND origin LIKE ?" + params.append(f"{origin_prefix}%") + + query += " GROUP BY status" + cursor.execute(query, params) counts_result = cast(list[sqlite3.Row], cursor.fetchall()) # When user_id is provided, additionally compute that user's own counts so the @@ -1164,18 +1184,24 @@ def get_queue_status( # separate user_pending/user_in_progress fields and never replace the global counts. user_counts_result: list[sqlite3.Row] = [] if user_id is not None: - cursor.execute( - """--sql + user_query = """--sql SELECT status, count(*) FROM session_queue WHERE queue_id = ? AND user_id = ? + """ + user_params = [queue_id, user_id] + + if origin_prefix is not None: + user_query += " AND origin LIKE ?" + user_params.append(f"{origin_prefix}%") + + user_query += """--sql GROUP BY status - """, - (queue_id, user_id), - ) + """ + cursor.execute(user_query, user_params) user_counts_result = cast(list[sqlite3.Row], cursor.fetchall()) - current_item = self.get_current(queue_id=queue_id) + current_item = self.get_current(queue_id=queue_id, origin_prefix=origin_prefix) total = sum(row[1] or 0 for row in counts_result) counts: dict[str, int] = {row[0]: row[1] for row in counts_result} diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py index 9036048bced..4d64addc7d9 100644 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py @@ -37,7 +37,6 @@ def _repair_model_relationships_fks(self, cursor: sqlite3.Cursor) -> None: # Foreign keys already point at the correct table, nothing to repair. return - # Rebuild the table with the correct foreign keys referencing models(id). cursor.execute("ALTER TABLE model_relationships RENAME TO model_relationships_old;") cursor.execute( """ @@ -55,7 +54,7 @@ def _repair_model_relationships_fks(self, cursor: sqlite3.Cursor) -> None: """ ) - # Copy over the existing links, dropping any orphaned rows whose model keys no + # Copy over existing links, dropping orphaned rows whose model keys no # longer exist -- these would violate the restored foreign keys. cursor.execute( """ diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_33.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_33.py index 6f941154c58..44d54803d1f 100644 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_33.py +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_33.py @@ -1,70 +1,61 @@ +"""Migration 33: Add the projects table for server-side workbench project persistence. + +Projects are the v7 workbench's primary unit of work (spec: State Ownership). +Each row stores one user-owned project document as opaque JSON, plus a +monotonic revision used for optimistic concurrency: clients send the revision +they loaded, and a save against a stale revision is rejected so concurrent +tabs/devices cannot silently overwrite each other. +""" + import sqlite3 from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration class Migration33Callback: + """Migration to add the projects table.""" + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._create_projects_table(cursor) + + def _create_projects_table(self, cursor: sqlite3.Cursor) -> None: cursor.execute( """--sql - CREATE TABLE IF NOT EXISTS image_subfolder_move_jobs ( - id INTEGER PRIMARY KEY, - state TEXT NOT NULL CHECK ( - state IN ('planned', 'moving', 'moved', 'committed', 'error') - ), + CREATE TABLE projects ( + -- Client-generated identifier; unique per user, not globally. + project_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + -- Opaque client-owned project document (JSON). + data TEXT NOT NULL, + -- Incremented on every update; used for optimistic concurrency. + revision INTEGER NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), - error_message TEXT - ); - """ - ) - cursor.execute( - """--sql - CREATE TABLE IF NOT EXISTS image_subfolder_move_items ( - job_id INTEGER NOT NULL REFERENCES image_subfolder_move_jobs(id), - image_name TEXT NOT NULL REFERENCES images(image_name), - old_subfolder TEXT NOT NULL, - new_subfolder TEXT NOT NULL, - is_intermediate BOOLEAN NOT NULL DEFAULT FALSE, - old_path TEXT, - new_path TEXT, - old_thumbnail_path TEXT, - new_thumbnail_path TEXT, - state TEXT NOT NULL CHECK ( - state IN ('planned', 'moved', 'committed', 'error') - ), - error_message TEXT, - PRIMARY KEY (job_id, image_name) + PRIMARY KEY (user_id, project_id), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE ); """ ) cursor.execute( """--sql - CREATE INDEX IF NOT EXISTS idx_image_subfolder_move_items_job_state - ON image_subfolder_move_items(job_id, state); - """ - ) - cursor.execute( - """--sql - CREATE INDEX IF NOT EXISTS idx_image_subfolder_move_items_image_name - ON image_subfolder_move_items(image_name); - """ - ) - cursor.execute( - """--sql - CREATE TRIGGER IF NOT EXISTS tg_image_subfolder_move_jobs_updated_at - AFTER UPDATE - ON image_subfolder_move_jobs FOR EACH ROW + CREATE TRIGGER tg_projects_updated_at + AFTER UPDATE ON projects + FOR EACH ROW BEGIN - UPDATE image_subfolder_move_jobs + UPDATE projects SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') - WHERE id = old.id; + WHERE user_id = OLD.user_id AND project_id = OLD.project_id; END; """ ) def build_migration_33() -> Migration: + """Builds the migration object for migrating from version 32 to version 33. This includes: + - Creating the `projects` table for per-user workbench project persistence. + - Adding a trigger to keep `updated_at` current. + """ return Migration( from_version=32, to_version=33, diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_34.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_34.py new file mode 100644 index 00000000000..82d428ad248 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_34.py @@ -0,0 +1,72 @@ +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration34Callback: + def __call__(self, cursor: sqlite3.Cursor) -> None: + cursor.execute( + """--sql + CREATE TABLE IF NOT EXISTS image_subfolder_move_jobs ( + id INTEGER PRIMARY KEY, + state TEXT NOT NULL CHECK ( + state IN ('planned', 'moving', 'moved', 'committed', 'error') + ), + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + error_message TEXT + ); + """ + ) + cursor.execute( + """--sql + CREATE TABLE IF NOT EXISTS image_subfolder_move_items ( + job_id INTEGER NOT NULL REFERENCES image_subfolder_move_jobs(id), + image_name TEXT NOT NULL REFERENCES images(image_name), + old_subfolder TEXT NOT NULL, + new_subfolder TEXT NOT NULL, + is_intermediate BOOLEAN NOT NULL DEFAULT FALSE, + old_path TEXT, + new_path TEXT, + old_thumbnail_path TEXT, + new_thumbnail_path TEXT, + state TEXT NOT NULL CHECK ( + state IN ('planned', 'moved', 'committed', 'error') + ), + error_message TEXT, + PRIMARY KEY (job_id, image_name) + ); + """ + ) + cursor.execute( + """--sql + CREATE INDEX IF NOT EXISTS idx_image_subfolder_move_items_job_state + ON image_subfolder_move_items(job_id, state); + """ + ) + cursor.execute( + """--sql + CREATE INDEX IF NOT EXISTS idx_image_subfolder_move_items_image_name + ON image_subfolder_move_items(image_name); + """ + ) + cursor.execute( + """--sql + CREATE TRIGGER IF NOT EXISTS tg_image_subfolder_move_jobs_updated_at + AFTER UPDATE + ON image_subfolder_move_jobs FOR EACH ROW + BEGIN + UPDATE image_subfolder_move_jobs + SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE id = old.id; + END; + """ + ) + + +def build_migration_34() -> Migration: + return Migration( + from_version=33, + to_version=34, + callback=Migration34Callback(), + ) diff --git a/invokeai/backend/image_util/segment_anything/segment_anything_2_pipeline.py b/invokeai/backend/image_util/segment_anything/segment_anything_2_pipeline.py index 79a7d91a7bf..47fdc6c41e8 100644 --- a/invokeai/backend/image_util/segment_anything/segment_anything_2_pipeline.py +++ b/invokeai/backend/image_util/segment_anything/segment_anything_2_pipeline.py @@ -2,8 +2,6 @@ import torch from PIL import Image - -# Import SAM2 components - these should be available in transformers 4.56.0+ from transformers.models.sam2 import Sam2Model from transformers.models.sam2.processing_sam2 import Sam2Processor @@ -101,7 +99,6 @@ def segment( masks = self._sam2_processor.post_process_masks( masks=outputs.pred_masks, original_sizes=processed_inputs.original_sizes, - reshaped_input_sizes=processed_inputs.reshaped_input_sizes, ) # There should be only one batch. diff --git a/invokeai/backend/model_manager/load/model_cache/cache_stats.py b/invokeai/backend/model_manager/load/model_cache/cache_stats.py index 4998ac6c77a..c339d3b7ad1 100644 --- a/invokeai/backend/model_manager/load/model_cache/cache_stats.py +++ b/invokeai/backend/model_manager/load/model_cache/cache_stats.py @@ -9,6 +9,7 @@ class CacheStats(object): hits: int = 0 # cache hits misses: int = 0 # cache misses high_watermark: int = 0 # amount of cache used + cache_used: int = 0 # current amount of cache used in_cache: int = 0 # number of models in cache cleared: int = 0 # number of models cleared to make space cache_size: int = 0 # total size of cache diff --git a/invokeai/backend/model_manager/load/model_cache/model_cache.py b/invokeai/backend/model_manager/load/model_cache/model_cache.py index e3a0928e52b..6b5da659848 100644 --- a/invokeai/backend/model_manager/load/model_cache/model_cache.py +++ b/invokeai/backend/model_manager/load/model_cache/model_cache.py @@ -77,6 +77,12 @@ class CacheEntrySnapshot: current_vram_bytes: int +@dataclass(frozen=True) +class CacheClearResult: + models_cleared: int + bytes_freed: int + + class CacheMissCallback(Protocol): def __call__( self, @@ -360,6 +366,7 @@ def put(self, key: str, model: AnyModel, execution_device: Optional[torch.device cache_record = CacheRecord(key=key, cached_model=wrapped_model) self._cached_models[key] = cache_record self._cache_stack.append(key) + self._sync_current_stats() self._logger.debug( f"Added model {key} (Type: {model.__class__.__name__}, Wrap mode: {wrapped_model.__class__.__name__}, Model size: {size / MB:.2f}MB)" ) @@ -378,6 +385,11 @@ def _get_cache_snapshot(self) -> dict[str, CacheEntrySnapshot]: return overview + def _sync_current_stats(self) -> None: + if self.stats: + self.stats.cache_used = self._get_ram_in_use() + self.stats.in_cache = len(self._cached_models) + @synchronized @record_activity def get(self, key: str, stats_name: Optional[str] = None) -> CacheRecord: @@ -405,6 +417,7 @@ def get(self, key: str, stats_name: Optional[str] = None) -> CacheRecord: if self.stats: stats_name = stats_name or key self.stats.high_watermark = max(self.stats.high_watermark, self._get_ram_in_use()) + self.stats.cache_used = self._get_ram_in_use() self.stats.in_cache = len(self._cached_models) self.stats.loaded_model_sizes[stats_name] = max( self.stats.loaded_model_sizes.get(stats_name, 0), cache_entry.cached_model.total_bytes() @@ -485,6 +498,7 @@ def unlock(self, cache_entry: CacheRecord) -> None: self._delete_cache_entry(cache_entry) if self.stats: self.stats.cleared = (self.stats.cleared or 0) + 1 + self._sync_current_stats() snapshot = self._get_cache_snapshot() for cb in self._on_cache_models_cleared_callbacks: cb( @@ -820,16 +834,16 @@ def _log_cache_state(self, title: str = "Model cache state:", include_entry_deta self._logger.debug(log) @synchronized - def make_room(self, bytes_needed: int) -> None: + def make_room(self, bytes_needed: int) -> CacheClearResult: """Make enough room in the cache to accommodate a new model of indicated size. Note: This function deletes all of the cache's internal references to a model in order to free it. If there are external references to the model, there's nothing that the cache can do about it, and those models will not be garbage-collected. """ - self._make_room_internal(bytes_needed) + return self._make_room_internal(bytes_needed) - def _make_room_internal(self, bytes_needed: int) -> None: + def _make_room_internal(self, bytes_needed: int) -> CacheClearResult: """Internal implementation of make_room(). Assumes the lock is already held.""" self._logger.debug(f"Making room for {bytes_needed / MB:.2f}MB of RAM.") self._log_cache_state(title="Before dropping models:") @@ -878,9 +892,12 @@ def _make_room_internal(self, bytes_needed: int) -> None: ) gc.collect() + self._sync_current_stats() + TorchDevice.empty_cache() self._logger.debug(f"Dropped {models_cleared} models to free {ram_bytes_freed / MB:.2f}MB of RAM.") self._log_cache_state(title="After dropping models:") + return CacheClearResult(models_cleared=models_cleared, bytes_freed=ram_bytes_freed) def _delete_cache_entry(self, cache_entry: CacheRecord) -> None: """Delete cache_entry from the cache if it exists. No exception is thrown if it doesn't exist.""" @@ -918,6 +935,7 @@ def drop_model(self, model_key: str) -> int: if dropped: if self.stats: self.stats.cleared = len(dropped) + self._sync_current_stats() snapshot = self._get_cache_snapshot() for cb in self._on_cache_models_cleared_callbacks: cb( diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index e2801e9e39a..4191149397c 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -728,6 +728,27 @@ } } }, + "/api/v2/models/models_dir": { + "get": { + "tags": ["model_manager"], + "summary": "Get Models Dir", + "description": "Get the absolute path of the directory managed models are stored in.\n\nModel config `path` values are relative to this directory unless they are\nabsolute (in-place installs from outside it).", + "operationId": "get_models_dir", + "responses": { + "200": { + "description": "The absolute path of the models directory", + "content": { + "application/json": { + "schema": { + "type": "string", + "title": "Response Get Models Dir" + } + } + } + } + } + } + }, "/api/v2/models/get_by_attrs": { "get": { "tags": ["model_manager"], @@ -2905,6 +2926,24 @@ "title": "Access Token" }, "description": "access token for the remote resource" + }, + { + "name": "X-Model-Source-Access-Token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "access token for the remote resource", + "title": "X-Model-Source-Access-Token" + }, + "description": "access token for the remote resource" } ], "requestBody": { @@ -3795,7 +3834,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/EmptyModelCacheResponse" + } } } } @@ -3823,7 +3864,12 @@ } } } - } + }, + "security": [ + { + "HTTPBearer": [] + } + ] }, "post": { "tags": ["model_manager"], @@ -6621,7 +6667,12 @@ } } } - } + }, + "security": [ + { + "HTTPBearer": [] + } + ] } }, "/api/v1/app/external_providers/config": { @@ -6644,7 +6695,12 @@ } } } - } + }, + "security": [ + { + "HTTPBearer": [] + } + ] } }, "/api/v1/app/external_providers/config/{provider_id}": { @@ -7054,6 +7110,24 @@ "default": "DESC" }, "description": "The order of sort" + }, + { + "name": "origin_prefix", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Only include queue items whose origin starts with this prefix", + "title": "Origin Prefix" + }, + "description": "Only include queue items whose origin starts with this prefix" } ], "responses": { @@ -7632,6 +7706,24 @@ "title": "Queue Id" }, "description": "The queue id to perform this operation on" + }, + { + "name": "origin_prefix", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Only include queue items whose origin starts with this prefix", + "title": "Origin Prefix" + }, + "description": "Only include queue items whose origin starts with this prefix" } ], "responses": { @@ -7694,6 +7786,24 @@ "title": "Queue Id" }, "description": "The queue id to perform this operation on" + }, + { + "name": "origin_prefix", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Only include queue items whose origin starts with this prefix", + "title": "Origin Prefix" + }, + "description": "Only include queue items whose origin starts with this prefix" } ], "responses": { @@ -7756,6 +7866,24 @@ "title": "Queue Id" }, "description": "The queue id to perform this operation on" + }, + { + "name": "origin_prefix", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Only include queue items whose origin starts with this prefix", + "title": "Origin Prefix" + }, + "description": "Only include queue items whose origin starts with this prefix" } ], "responses": { @@ -9697,6 +9825,223 @@ } } }, + "/api/v1/projects/": { + "get": { + "tags": ["projects"], + "summary": "List Projects", + "description": "Lists the current user's projects as lightweight summaries (no documents).", + "operationId": "list_projects", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ProjectSummaryDTO" + }, + "type": "array", + "title": "Response List Projects" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "tags": ["projects"], + "summary": "Create Project", + "description": "Creates a project for the current user.", + "operationId": "create_project", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCreateRequest", + "description": "The project to create" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRecordDTO" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/projects/{project_id}": { + "get": { + "tags": ["projects"], + "summary": "Get Project", + "description": "Gets one of the current user's projects, including its document.", + "operationId": "get_project", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The id of the project to get", + "title": "Project Id" + }, + "description": "The id of the project to get" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRecordDTO" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": ["projects"], + "summary": "Update Project", + "description": "Saves a project. Returns 409 with the current revision when the save is based on a stale revision.", + "operationId": "update_project", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The id of the project to save", + "title": "Project Id" + }, + "description": "The id of the project to save" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectUpdateRequest", + "description": "The project document and the revision it is based on" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRecordDTO" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": ["projects"], + "summary": "Delete Project", + "description": "Deletes one of the current user's projects. Idempotent.", + "operationId": "delete_project", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The id of the project to delete", + "title": "Project Id" + }, + "description": "The id of the project to delete" + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/recall/{queue_id}": { "post": { "tags": ["recall"], @@ -14722,6 +15067,11 @@ "title": "High Watermark", "default": 0 }, + "cache_used": { + "type": "integer", + "title": "Cache Used", + "default": 0 + }, "in_cache": { "type": "integer", "title": "In Cache", @@ -22597,6 +22947,21 @@ "required": ["node_id", "field"], "title": "EdgeConnection" }, + "EmptyModelCacheResponse": { + "properties": { + "models_cleared": { + "type": "integer", + "title": "Models Cleared" + }, + "bytes_freed": { + "type": "integer", + "title": "Bytes Freed" + } + }, + "type": "object", + "required": ["models_cleared", "bytes_freed"], + "title": "EmptyModelCacheResponse" + }, "EnqueueBatchResult": { "properties": { "queue_id": { @@ -59795,6 +60160,133 @@ "title": "ProgressImage", "type": "object" }, + "ProjectCreateRequest": { + "properties": { + "project_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Project Id", + "description": "Client-generated project id (e.g. for imports); generated when omitted" + }, + "name": { + "type": "string", + "title": "Name", + "description": "The project's display name" + }, + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data", + "description": "The opaque client-owned project document" + } + }, + "type": "object", + "required": ["name", "data"], + "title": "ProjectCreateRequest", + "description": "Request body for creating a project." + }, + "ProjectRecordDTO": { + "properties": { + "project_id": { + "type": "string", + "title": "Project Id", + "description": "The project's client-generated identifier" + }, + "name": { + "type": "string", + "title": "Name", + "description": "The project's display name" + }, + "revision": { + "type": "integer", + "title": "Revision", + "description": "Monotonic revision, incremented on every save" + }, + "created_at": { + "type": "string", + "title": "Created At", + "description": "When the project was created" + }, + "updated_at": { + "type": "string", + "title": "Updated At", + "description": "When the project was last saved" + }, + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data", + "description": "The opaque client-owned project document" + } + }, + "type": "object", + "required": ["project_id", "name", "revision", "created_at", "updated_at", "data"], + "title": "ProjectRecordDTO", + "description": "Full project record including the client-owned document." + }, + "ProjectSummaryDTO": { + "properties": { + "project_id": { + "type": "string", + "title": "Project Id", + "description": "The project's client-generated identifier" + }, + "name": { + "type": "string", + "title": "Name", + "description": "The project's display name" + }, + "revision": { + "type": "integer", + "title": "Revision", + "description": "Monotonic revision, incremented on every save" + }, + "created_at": { + "type": "string", + "title": "Created At", + "description": "When the project was created" + }, + "updated_at": { + "type": "string", + "title": "Updated At", + "description": "When the project was last saved" + } + }, + "type": "object", + "required": ["project_id", "name", "revision", "created_at", "updated_at"], + "title": "ProjectSummaryDTO", + "description": "Lightweight project listing entry; the document payload is omitted." + }, + "ProjectUpdateRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The project's display name" + }, + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data", + "description": "The opaque client-owned project document" + }, + "expected_revision": { + "type": "integer", + "title": "Expected Revision", + "description": "The revision this save is based on; mismatch returns 409" + } + }, + "type": "object", + "required": ["name", "data", "expected_revision"], + "title": "ProjectUpdateRequest", + "description": "Request body for saving a project with optimistic concurrency." + }, "PromptTemplateInvocation": { "category": "prompt", "class": "invocation", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/__mocks__/mockStagingAreaApp.ts b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/__mocks__/mockStagingAreaApp.ts index e0b56d5a439..bd848127756 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/__mocks__/mockStagingAreaApp.ts +++ b/invokeai/frontend/web/src/features/controlLayers/components/StagingArea/__mocks__/mockStagingAreaApp.ts @@ -165,6 +165,7 @@ export const createMockQueueItemStatusChangedEvent = ( status: 'completed', error_type: null, error_message: null, + error_traceback: null, } as S['QueueItemStatusChangedEvent'], overrides ); diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index f938b7f0f2c..6ec09bef539 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -388,6 +388,29 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v2/models/models_dir": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Models Dir + * @description Get the absolute path of the directory managed models are stored in. + * + * Model config `path` values are relative to this directory unless they are + * absolute (in-place installs from outside it). + */ + get: operations["get_models_dir"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/models/get_by_attrs": { parameters: { query?: never; @@ -2704,6 +2727,58 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/projects/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Projects + * @description Lists the current user's projects as lightweight summaries (no documents). + */ + get: operations["list_projects"]; + put?: never; + /** + * Create Project + * @description Creates a project for the current user. + */ + post: operations["create_project"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/projects/{project_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Project + * @description Gets one of the current user's projects, including its document. + */ + get: operations["get_project"]; + /** + * Update Project + * @description Saves a project. Returns 409 with the current revision when the save is based on a stale revision. + */ + put: operations["update_project"]; + post?: never; + /** + * Delete Project + * @description Deletes one of the current user's projects. Idempotent. + */ + delete: operations["delete_project"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/recall/{queue_id}": { parameters: { query?: never; @@ -5349,6 +5424,11 @@ export type components = { * @default 0 */ high_watermark?: number; + /** + * Cache Used + * @default 0 + */ + cache_used?: number; /** * In Cache * @default 0 @@ -9325,6 +9405,13 @@ export type components = { */ field: string; }; + /** EmptyModelCacheResponse */ + EmptyModelCacheResponse: { + /** Models Cleared */ + models_cleared: number; + /** Bytes Freed */ + bytes_freed: number; + }; /** EnqueueBatchResult */ EnqueueBatchResult: { /** @@ -25266,6 +25353,121 @@ export type components = { */ dataURL: string; }; + /** + * ProjectCreateRequest + * @description Request body for creating a project. + */ + ProjectCreateRequest: { + /** + * Project Id + * @description Client-generated project id (e.g. for imports); generated when omitted + */ + project_id?: string | null; + /** + * Name + * @description The project's display name + */ + name: string; + /** + * Data + * @description The opaque client-owned project document + */ + data: { + [key: string]: unknown; + }; + }; + /** + * ProjectRecordDTO + * @description Full project record including the client-owned document. + */ + ProjectRecordDTO: { + /** + * Project Id + * @description The project's client-generated identifier + */ + project_id: string; + /** + * Name + * @description The project's display name + */ + name: string; + /** + * Revision + * @description Monotonic revision, incremented on every save + */ + revision: number; + /** + * Created At + * @description When the project was created + */ + created_at: string; + /** + * Updated At + * @description When the project was last saved + */ + updated_at: string; + /** + * Data + * @description The opaque client-owned project document + */ + data: { + [key: string]: unknown; + }; + }; + /** + * ProjectSummaryDTO + * @description Lightweight project listing entry; the document payload is omitted. + */ + ProjectSummaryDTO: { + /** + * Project Id + * @description The project's client-generated identifier + */ + project_id: string; + /** + * Name + * @description The project's display name + */ + name: string; + /** + * Revision + * @description Monotonic revision, incremented on every save + */ + revision: number; + /** + * Created At + * @description When the project was created + */ + created_at: string; + /** + * Updated At + * @description When the project was last saved + */ + updated_at: string; + }; + /** + * ProjectUpdateRequest + * @description Request body for saving a project with optimistic concurrency. + */ + ProjectUpdateRequest: { + /** + * Name + * @description The project's display name + */ + name: string; + /** + * Data + * @description The opaque client-owned project document + */ + data: { + [key: string]: unknown; + }; + /** + * Expected Revision + * @description The revision this save is based on; mismatch returns 409 + */ + expected_revision: number; + }; /** * Prompt Template * @description Applies a Style Preset template to positive and negative prompts. @@ -34544,6 +34746,26 @@ export interface operations { }; }; }; + get_models_dir: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The absolute path of the models directory */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + }; + }; get_model_records_by_attrs: { parameters: { query: { @@ -35158,7 +35380,10 @@ export interface operations { /** @description access token for the remote resource */ access_token?: string | null; }; - header?: never; + header?: { + /** @description access token for the remote resource */ + "X-Model-Source-Access-Token"?: string | null; + }; path?: never; cookie?: never; }; @@ -35655,7 +35880,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["EmptyModelCacheResponse"]; }; }; }; @@ -37839,6 +38064,8 @@ export interface operations { query?: { /** @description The order of sort */ order_dir?: components["schemas"]["SQLiteDirection"]; + /** @description Only include queue items whose origin starts with this prefix */ + origin_prefix?: string | null; }; header?: never; path: { @@ -38206,7 +38433,10 @@ export interface operations { }; get_current_queue_item: { parameters: { - query?: never; + query?: { + /** @description Only include queue items whose origin starts with this prefix */ + origin_prefix?: string | null; + }; header?: never; path: { /** @description The queue id to perform this operation on */ @@ -38238,7 +38468,10 @@ export interface operations { }; get_next_queue_item: { parameters: { - query?: never; + query?: { + /** @description Only include queue items whose origin starts with this prefix */ + origin_prefix?: string | null; + }; header?: never; path: { /** @description The queue id to perform this operation on */ @@ -38270,7 +38503,10 @@ export interface operations { }; get_queue_status: { parameters: { - query?: never; + query?: { + /** @description Only include queue items whose origin starts with this prefix */ + origin_prefix?: string | null; + }; header?: never; path: { /** @description The queue id to perform this operation on */ @@ -39422,6 +39658,157 @@ export interface operations { }; }; }; + list_projects: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectSummaryDTO"][]; + }; + }; + }; + }; + create_project: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProjectCreateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectRecordDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_project: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The id of the project to get */ + project_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectRecordDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_project: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The id of the project to save */ + project_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProjectUpdateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectRecordDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_project: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The id of the project to delete */ + project_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_recall_parameters: { parameters: { query?: never; diff --git a/invokeai/frontend/webv2/.gitignore b/invokeai/frontend/webv2/.gitignore new file mode 100644 index 00000000000..22f1644ccfa --- /dev/null +++ b/invokeai/frontend/webv2/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +stats.html diff --git a/invokeai/frontend/webv2/.oxfmtrc.json b/invokeai/frontend/webv2/.oxfmtrc.json new file mode 100644 index 00000000000..a261d816261 --- /dev/null +++ b/invokeai/frontend/webv2/.oxfmtrc.json @@ -0,0 +1,26 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "arrowParens": "always", + "bracketSameLine": false, + "bracketSpacing": true, + "endOfLine": "lf", + "ignorePatterns": ["dist/**", "node_modules/**", "stats.html"], + "printWidth": 120, + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "sortImports": { + "groups": [ + "type-import", + ["value-builtin", "value-external"], + ["type-internal", "value-internal"], + ["type-parent", "type-sibling", "type-index"], + ["value-parent", "value-sibling", "value-index"], + "unknown" + ] + }, + "sortPackageJson": { + "sortScripts": true + } +} diff --git a/invokeai/frontend/webv2/.oxlintrc.json b/invokeai/frontend/webv2/.oxlintrc.json new file mode 100644 index 00000000000..aba3b3877a9 --- /dev/null +++ b/invokeai/frontend/webv2/.oxlintrc.json @@ -0,0 +1,137 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "react", "import", "unicorn", "oxc", "react-perf", "eslint"], + "categories": { + "correctness": "error" + }, + "rules": { + "curly": "error", + "eqeqeq": "error", + + "import/no-cycle": "error", + "import/no-duplicates": "error", + "import/no-relative-parent-imports": "error", + "import/no-absolute-path": "error", + "import/no-amd": "error", + "import/no-commonjs": "error", + "import/no-mutable-exports": "error", + + "no-console": "warn", + "no-eval": "error", + "no-extend-native": "error", + "no-implied-eval": "error", + "no-label-var": "error", + "no-promise-executor-return": "error", + "no-return-assign": "error", + "no-sequences": "error", + "no-template-curly-in-string": "error", + "no-throw-literal": "error", + "no-unmodified-loop-condition": "error", + "no-var": "error", + + "prefer-template": "error", + "radix": "error", + "no-restricted-exports": "error", + + // "eslint/complexity": [ + // "error", + // { + // "max": 3 + // } + // ], + + "react/jsx-curly-brace-presence": [ + "error", + { + "props": "never", + "children": "never" + } + ], + "react/no-children-prop": "error", + "react/no-danger": "error", + "react/no-unknown-property": "error", + "react/jsx-no-useless-fragment": "error", + "react/exhaustive-deps": "error", + "react/react-compiler": "error", + + "react-hooks/exhaustive-deps": "error", + "react-hooks/rules-of-hooks": "error", + + "react-perf/jsx-no-new-object-as-prop": [ + "error", + { + "nativeAllowList": "all" + } + ], + "react-perf/jsx-no-new-function-as-prop": [ + "error", + { + "nativeAllowList": "all" + } + ], + "react-perf/jsx-no-new-array-as-prop": [ + "error", + { + "nativeAllowList": "all" + } + ], + "react-perf/jsx-no-jsx-as-prop": [ + "error", + { + "nativeAllowList": "all" + } + ], + + "require-await": "error", + + "typescript/consistent-type-imports": [ + "error", + { + "fixStyle": "separate-type-imports", + "prefer": "type-imports" + } + ], + "typescript/no-empty-interface": [ + "error", + { + "allowSingleExtends": true + } + ], + "typescript/consistent-type-assertions": [ + "error", + { + "assertionStyle": "as" + } + ], + "typescript/ban-ts-comment": "error", + "typescript/no-import-type-side-effects": "error", + + "unicorn/no-abusive-eslint-disable": "error", + "unicorn/error-message": "error", + "unicorn/throw-new-error": "error", + "unicorn/prefer-type-error": "error", + + "vitest/require-mock-type-parameters": "off" + }, + "env": { + "browser": true, + "builtin": true, + "es2022": true, + "node": true + }, + "globals": { + "GlobalCompositeOperation": "readonly", + "RequestInit": "readonly" + }, + "ignorePatterns": [ + "dist/**", + "node_modules/**", + "static/**", + ".husky/**", + "patches/**", + "stats.html", + "index.html", + ".yarn/**", + "**/*.scss" + ] +} diff --git a/invokeai/frontend/webv2/AGENTS.md b/invokeai/frontend/webv2/AGENTS.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/invokeai/frontend/webv2/OXC_RULE_COMPATIBILITY.md b/invokeai/frontend/webv2/OXC_RULE_COMPATIBILITY.md new file mode 100644 index 00000000000..2af36ab0b91 --- /dev/null +++ b/invokeai/frontend/webv2/OXC_RULE_COMPATIBILITY.md @@ -0,0 +1,23 @@ +# OXC Rule Compatibility + +`webv2` uses `oxlint` and `oxfmt` instead of ESLint and Prettier. + +The config mirrors the existing Invoke web lint intent where OXC supports equivalent rules: + +- React and hooks checks: `react/jsx-no-bind`, `react/jsx-curly-brace-presence`, `react-hooks/*`. +- TypeScript checks: `typescript/consistent-type-imports`, `typescript/no-empty-interface`, plus TypeScript compile checks through `tsc --noEmit`. +- Import checks: `import/no-duplicates`, `import/no-cycle`. +- General correctness/style checks: `curly`, `no-var`, `prefer-template`, `radix`, `eqeqeq`, `no-eval`, `no-extend-native`, `no-implied-eval`, `no-label-var`, `no-return-assign`, `no-sequences`, `no-template-curly-in-string`, `no-throw-literal`, `no-unmodified-loop-condition`, `no-console`, `no-promise-executor-return`, and `require-await`. +- Formatting: `oxfmt` uses the same print width, tab width, semicolon, single quote, and trailing comma preferences as the existing web formatter. It uses `lf` line endings because `oxfmt` does not support Prettier's `auto` value. + +Known unsupported or intentionally deferred equivalents: + +- `brace-style`, `one-var`, and `react/jsx-no-bind`: oxlint 1.69.0 does not expose these ESLint/plugin rule names. +- `simple-import-sort/*`: oxlint does not currently provide the same configurable import sorting behavior. +- `unused-imports/no-unused-imports`: oxlint reports unused bindings, but it is not the same plugin rule. +- `@typescript-eslint/ban-ts-comment`: no exact oxlint equivalent is configured yet. +- `@typescript-eslint/no-import-type-side-effects`: no exact oxlint equivalent is configured yet. +- `@typescript-eslint/consistent-type-assertions`: no exact oxlint equivalent is configured yet. +- `path/no-relative-imports`: no exact oxlint equivalent is configured yet. +- `no-restricted-syntax`, `no-restricted-properties`, `no-restricted-imports`: no exact oxlint config equivalent is configured yet. +- `i18next/no-literal-string` and Storybook-specific overrides: not configured for the initial `webv2` shell. diff --git a/invokeai/frontend/webv2/index.html b/invokeai/frontend/webv2/index.html new file mode 100644 index 00000000000..ef74665c231 --- /dev/null +++ b/invokeai/frontend/webv2/index.html @@ -0,0 +1,54 @@ + + + + + + Invoke V7 Workbench + + + +
+ + + diff --git a/invokeai/frontend/webv2/package.json b/invokeai/frontend/webv2/package.json new file mode 100644 index 00000000000..e3b8b58ae21 --- /dev/null +++ b/invokeai/frontend/webv2/package.json @@ -0,0 +1,70 @@ +{ + "name": "@invoke-ai/invoke-ai-webv2", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm run lint && vite build", + "dev": "vite dev", + "dev:host": "vite dev --host", + "fix": "pnpm run lint:oxc:fix && pnpm run format:write", + "format:check": "oxfmt --check .", + "format:write": "oxfmt --write .", + "i18n:report": "node scripts/i18n-report.mjs", + "i18n:report:verbose": "node scripts/i18n-report.mjs --verbose", + "lint": "pnpm run format:check && pnpm run lint:oxc && pnpm run lint:tsc", + "lint:oxc": "oxlint --react-plugin --import-plugin --deny-warnings .", + "lint:oxc:fix": "oxlint --react-plugin --import-plugin --fix .", + "lint:tsc": "tsc --noEmit", + "preview": "vite preview", + "test": "vitest run --config vitest.config.mts", + "test:all": "pnpm run test && pnpm run test:browser", + "test:browser": "vitest run --config vitest.browser.config.mts" + }, + "dependencies": { + "@chakra-ui/react": "^3.36.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@emotion/react": "^11.14.0", + "@fontsource/inter": "^5.2.8", + "@tanstack/react-router": "^1.170.16", + "@tanstack/react-virtual": "^3.14.3", + "@xyflow/react": "^12.11.1", + "ag-psd": "^31.0.1", + "i18next": "^25.7.3", + "i18next-http-backend": "^3.0.2", + "lucide-react": "^1.21.0", + "perfect-freehand": "^1.2.3", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-hook-tanstack-virtual": "^0.0.4", + "react-i18next": "^16.5.0", + "react-icons": "^5.6.0", + "socket.io-client": "^4.8.3", + "tinykeys": "^4.0.0", + "use-sync-external-store": "^1.6.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@rolldown/plugin-babel": "^0.2.3", + "@types/node": "^26.0.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@types/use-sync-external-store": "^1.5.0", + "@vitejs/plugin-react": "^6.0.3", + "@vitest/browser-playwright": "4.1.9", + "babel-plugin-react-compiler": "^1.0.0", + "oxfmt": "^0.56.0", + "oxlint": "^1.71.0", + "playwright": "1.61.1", + "typescript": "^6.0.3", + "vite": "^8.1.0", + "vitest": "^4.1.9" + }, + "engines": { + "pnpm": "10" + }, + "packageManager": "pnpm@10.12.4" +} diff --git a/invokeai/frontend/webv2/pnpm-lock.yaml b/invokeai/frontend/webv2/pnpm-lock.yaml new file mode 100644 index 00000000000..5dcfad18af3 --- /dev/null +++ b/invokeai/frontend/webv2/pnpm-lock.yaml @@ -0,0 +1,3949 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@chakra-ui/react': + specifier: ^3.36.0 + version: 3.36.0(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.7) + '@emotion/react': + specifier: ^11.14.0 + version: 11.14.0(@types/react@19.2.17)(react@19.2.7) + '@fontsource/inter': + specifier: ^5.2.8 + version: 5.2.8 + '@tanstack/react-router': + specifier: ^1.170.16 + version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-virtual': + specifier: ^3.14.3 + version: 3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@xyflow/react': + specifier: ^12.11.1 + version: 12.11.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + ag-psd: + specifier: ^31.0.1 + version: 31.0.1 + i18next: + specifier: ^25.7.3 + version: 25.10.10(typescript@6.0.3) + i18next-http-backend: + specifier: ^3.0.2 + version: 3.0.6 + lucide-react: + specifier: ^1.21.0 + version: 1.21.0(react@19.2.7) + perfect-freehand: + specifier: ^1.2.3 + version: 1.2.3 + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + react-hook-tanstack-virtual: + specifier: ^0.0.4 + version: 0.0.4(@tanstack/react-virtual@3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/virtual-core@3.17.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-i18next: + specifier: ^16.5.0 + version: 16.6.6(i18next@25.10.10(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) + react-icons: + specifier: ^5.6.0 + version: 5.6.0(react@19.2.7) + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 + tinykeys: + specifier: ^4.0.0 + version: 4.0.0 + use-sync-external-store: + specifier: ^1.6.0 + version: 1.6.0(react@19.2.7) + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@rolldown/plugin-babel': + specifier: ^0.2.3 + version: 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.1.3)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)) + '@types/node': + specifier: ^26.0.1 + version: 26.0.1 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@types/use-sync-external-store': + specifier: ^1.5.0 + version: 1.5.0 + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.1.3)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)) + '@vitest/browser-playwright': + specifier: 4.1.9 + version: 4.1.9(playwright@1.61.1)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7))(vitest@4.1.9) + babel-plugin-react-compiler: + specifier: ^1.0.0 + version: 1.0.0 + oxfmt: + specifier: ^0.56.0 + version: 0.56.0 + oxlint: + specifier: ^1.71.0 + version: 1.71.0 + playwright: + specifier: 1.61.1 + version: 1.61.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.1.0 + version: 8.1.0(@types/node@26.0.1)(esbuild@0.27.7) + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@26.0.1)(@vitest/browser-playwright@4.1.9)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)) + +packages: + + '@ark-ui/react@5.37.2': + resolution: {integrity: sha512-Q0R2Ah50kUhup0Ljxg65zGJq5yBV52BLm1coRkjHHid40d1yclaDGfhPL48kcF/xtjAFlGLkL6SiENkGvfh+mw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + + '@chakra-ui/react@3.36.0': + resolution: {integrity: sha512-6AxUbJsC6yyTzPeYL8sxyAL07lflT0NA+S6tcPzEuwdMux+benRMFOpPktnkifWKl/Vq/JD7fhxDyMuDQ4M0gA==} + peerDependencies: + '@emotion/react': '>=11' + react: '>=18' + react-dom: '>=18' + + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@fontsource/inter@5.2.8': + resolution: {integrity: sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==} + + '@internationalized/date@3.12.2': + resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} + + '@internationalized/number@3.6.6': + resolution: {integrity: sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + + '@oxfmt/binding-android-arm-eabi@0.56.0': + resolution: {integrity: sha512-CSCxi7ovYojgfdPOdUb9T508HKeAdDIKeRGg7x8IZwVJrWz9gVgX7MbUnFqtQAE4QvoNo07mj2JlwnOzJw4qqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.56.0': + resolution: {integrity: sha512-HYJFnd+PkDwf6S9ZPGzXXtjNqvRWFnnhdbWaouh4mi/SxU8wmDuzlMn3xo/wDTGnr4Q1VA7ZzOaE/D4biW0W6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.56.0': + resolution: {integrity: sha512-sftR/bEOr+t1gs+evwsHi/Xbq2FAPA2uU3VMr8n6ZU9PoK/IMSfnfu7+OEe/uy1+knhrFl4Wvy7Vkm3uo9mJ7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.56.0': + resolution: {integrity: sha512-z66SdjLqa3MUPKvTp3Mbb5nSjKSbnYxJGeB+Wx987s8T5hPcIRiBMfnJ6zcPgYtQn3x5xjvdzNVkXrSeYH6ZFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.56.0': + resolution: {integrity: sha512-t2tkrV1vtZyaItSQ71dTi2ZVKZEI39b/LqLT12V5KMfIeXK6N32TUC1jhOXKVQmhECq9j2ZXMQV3JeT1kh9Vmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.56.0': + resolution: {integrity: sha512-+gCy+Tp3RHeXQ9y/QrS76lXIpZkbziTyp6hIgjB2MssCwfMph3vG/GEfkhO34Rai1vhYIaUkvv8UT1BcDorJPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.56.0': + resolution: {integrity: sha512-0kKkVvQ2I+FJ2sxQyUu1zJ0yWP5kcWse/yVFnGQSFCXMwSSkfEaUGu0dW774O7nyy3jrcBGap7OSc8dZmU/CdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.56.0': + resolution: {integrity: sha512-npkA2siMbyWRh+wEhi1aTAx4RirukGcGNt8V4Ch86pG+xU9aurqS1MZOnKYMu03ISwat3rB6zkQx51SsB9obNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-arm64-musl@0.56.0': + resolution: {integrity: sha512-UekqOjGkV4/MkqreCV9SPIB2jlR3/HbXrmhV1rVXJZ9wfDXMyCMriLtq3tHqLY4PkbVWNtfcm1kMojJ26KLSJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-ppc64-gnu@0.56.0': + resolution: {integrity: sha512-XSzveSpeZMD5XJpew5lRFVtNnT04xd3rJxENXmk7wkZzN9oWzv2aFJyoNDhOtoz69BYaS/fg4SYl+CfEZRpB0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-gnu@0.56.0': + resolution: {integrity: sha512-EkQ0nJa7k7HDDIVuPF7WY+k4k+bzdclLYtyIXNt7/OqVghfNiMym6YGppFBgx1XRIHW6QylxBz5OogumPjPJbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-musl@0.56.0': + resolution: {integrity: sha512-dyjAGW8jKRge0ik6U/dgvQG0nVpA3iBlRskQTz5qJLvQWIrySxX5jpqzPetLBNIIZ231KA82fDdi1nLTk8ENCw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-s390x-gnu@0.56.0': + resolution: {integrity: sha512-60ZGH3LtfqlW8X6vcLdSFY4lvCQYINurttYBKaALnHCDVAUCYJ1LsUgS6p1XOzVlzEDx3yNUZvDF1Lvt59zoZw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxfmt/binding-linux-x64-gnu@0.56.0': + resolution: {integrity: sha512-u1suj1tgJHK4ZqB7buCtdbNef2n8+d0lXTPJwLHNmtyK6p+DTpsaoDvmqhQrA56fgKYv4LuRxNtL8YooebKOew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-linux-x64-musl@0.56.0': + resolution: {integrity: sha512-aYGLvlQHt80y+qKEtfJY/Nm27G0125Lv+qyh9SJ4Cjc6lXnXjD+ndfhqQnbV24POpMi7rNRi0jvx/0d70FRpCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-openharmony-arm64@0.56.0': + resolution: {integrity: sha512-H/re/gO+7ysVc+kywHNuzY3C33EN9sQcZhg0kp1ZwOZl7y998ZE5mhnBiuGR/nYI0pqLL5xQfrHVUOJ/cIJsCA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.56.0': + resolution: {integrity: sha512-6qLNXfXmtAs8jXDvYMkxk6Wec5SUJoew+ZX1uOZmqaR7ks0EJFbAohuOCELDyJMWyVlxotVG8Xf8m74Bfq0O2w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.56.0': + resolution: {integrity: sha512-UXEXuKphAe15bsob4AswNMArCw38XSmUIs3wk1s6e6MX9OWGW/IRWU95s1hZDiVg09STy1jHgyN2qkqbu1FT0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.56.0': + resolution: {integrity: sha512-HPyNDjky+NIOuaMvHZflR+kst3YWdUOH2JUQYkf99grqZ5mEBTQM6h9iGy501Z8Xt5xMScrwHOuVCOlqDrktRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.71.0': + resolution: {integrity: sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.71.0': + resolution: {integrity: sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.71.0': + resolution: {integrity: sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.71.0': + resolution: {integrity: sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.71.0': + resolution: {integrity: sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.71.0': + resolution: {integrity: sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.71.0': + resolution: {integrity: sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.71.0': + resolution: {integrity: sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.71.0': + resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.71.0': + resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.71.0': + resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.71.0': + resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.71.0': + resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.71.0': + resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.71.0': + resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.71.0': + resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.71.0': + resolution: {integrity: sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.71.0': + resolution: {integrity: sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.71.0': + resolution: {integrity: sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@pandacss/is-valid-prop@1.11.3': + resolution: {integrity: sha512-YaHK+p5DaN8AUpsRx5OqqGxaZzn8uNIdVhP+K1cjvjv3+Qa9D/75/A1dPyLmfKrSRJc8UoR9WN9fxQX0rVzhzQ==} + engines: {node: '>=20'} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/binding-android-arm64@1.1.3': + resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.3': + resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.3': + resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.3': + resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.1.3': + resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.1.3': + resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.1.3': + resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.1.3': + resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.3': + resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.3': + resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/plugin-babel@0.2.3': + resolution: {integrity: sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==} + engines: {node: '>=22.12.0 || ^24.0.0'} + peerDependencies: + '@babel/core': ^7.29.0 || ^8.0.0-rc.1 + '@babel/plugin-transform-runtime': ^7.29.0 || ^8.0.0-rc.1 + '@babel/runtime': ^7.27.0 || ^8.0.0-rc.1 + rolldown: ^1.0.0-rc.5 + vite: ^8.0.0 + peerDependenciesMeta: + '@babel/plugin-transform-runtime': + optional: true + '@babel/runtime': + optional: true + vite: + optional: true + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@tanstack/history@1.162.0': + resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} + engines: {node: '>=20.19'} + + '@tanstack/react-router@1.170.16': + resolution: {integrity: sha512-w6eq1IJklujs1tESazaK/FxH0+H2l8vm/QPuu1cD3oRW/ubgKneQpd7b64ti/8gUyEimzimJQZDmJr6YHfP5+g==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-virtual@3.14.3': + resolution: {integrity: sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.171.13': + resolution: {integrity: sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@tanstack/virtual-core@3.17.1': + resolution: {integrity: sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.0.1': + resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/use-sync-external-store@1.5.0': + resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==} + + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/browser-playwright@4.1.9': + resolution: {integrity: sha512-Bq1rOGf9waevzG3EOkO/dene6bvKTUsZMVg8S1i+WH3JcMjuXEjiahP9rAqZRELUqjBySOJsvvSWqK/B3wjKQw==} + peerDependencies: + playwright: '*' + vitest: 4.1.9 + + '@vitest/browser@4.1.9': + resolution: {integrity: sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg==} + peerDependencies: + vitest: 4.1.9 + + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + + '@xyflow/react@12.11.1': + resolution: {integrity: sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.78': + resolution: {integrity: sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==} + + '@zag-js/accordion@1.41.2': + resolution: {integrity: sha512-7G//V7svGGT8k5avw7bbQvbRC0Q/9QtX51b4iyAB1alR9E5mFd6Ch8q4njwcXClMQ7xePS3jUfVnzVGiRInEiQ==} + + '@zag-js/anatomy@1.41.2': + resolution: {integrity: sha512-Fm9hqdrvaCzCsdcf19G8WZxYtHElKltkGHdhqMEt4XU+ULTr1DK7KbOtDDv9J27CuzqSLALUz5QfRjPftoKHwg==} + + '@zag-js/angle-slider@1.41.2': + resolution: {integrity: sha512-+7bZHAZx0MEbjTMr2tD+meFJ0EJwFfUEcTqmdLzFGr/ySAMCWlcadDBz+ZmrSn03aKLps8FxliVLzsFJNgUqIQ==} + + '@zag-js/aria-hidden@1.41.2': + resolution: {integrity: sha512-qEcYmwlQr3qjA0T/IZ5a/o7fRUxfQ14tXjAFhR3GXCtxBKaqS+wnq/LN09Xw4bin3QWTINU+Z0oXFs9spWtNwA==} + + '@zag-js/async-list@1.41.2': + resolution: {integrity: sha512-NZZEIGFdeDp2uHjsLVegLAGJOYGwI9HPJI1V2c/P1TQmfmrfWyWELAvnnW4kWYVUKYD9TxKQkm6LvqpHrQzgfQ==} + + '@zag-js/auto-resize@1.41.2': + resolution: {integrity: sha512-CYq+JQ1TTkEiK7OcNcMTS0f4wFvtmManvUltije5o50gDQ7vFYA81oQh4A9pAB1keUi9Zv06CNefFARaTcne5w==} + + '@zag-js/avatar@1.41.2': + resolution: {integrity: sha512-+4K0tRtIQysMGuERh5JRmn46uq7gJ6IjJd5DKj74VBtVVY8T6HGk0D0DZxmOIgADHP1qSvowLDoOX8kUyBHarQ==} + + '@zag-js/carousel@1.41.2': + resolution: {integrity: sha512-H6sDxyWIzkvtHN4M/BYvFy7pi8j+kLEtfPZvgNl2+gRnfAHVK9/XqpoUsjw1DAo5Fh25pPuaTnh52Kml9OK0iA==} + + '@zag-js/cascade-select@1.41.2': + resolution: {integrity: sha512-dBByZmIAJU/B+YIAzczng05lCJXEwEm4df6GmUZtioKHZdeN+WEKUP4qzFDFZdytXympGfJCIBvtgf87gACYVQ==} + + '@zag-js/checkbox@1.41.2': + resolution: {integrity: sha512-aQ5dZWHUHRIw4cbLtzrR+dc8M0N6wSiiCml/zbU3ciHOTBXSrs2rKnp/L99xhi97Lj2uCEhpIZ704Ou/MjGuCg==} + + '@zag-js/clipboard@1.41.2': + resolution: {integrity: sha512-FM+PNGEeY+YpF1dVr9d8kg3DQM66LRnkR4Mva1oprqnrCXTYWj+k7uig893ubSG2xw1GO5b/WJd27kSmNoKnmw==} + + '@zag-js/collapsible@1.41.2': + resolution: {integrity: sha512-uMIp4rqd3iI6VFPMAOcbu9dh9WV4l85nNPYQiBtMKRHgbkfaZdfzF+E+NX3KquIeHW6JiBFUiIyCU0Sf2Cscdw==} + + '@zag-js/collection@1.41.2': + resolution: {integrity: sha512-ZZWuvfPZI8ccWd4aLpuU47k1jSc9eO+F3FM3iBJuvgegCH6g3+HDEwGN6wdePHnYfv7zyIKCGKr816zXcIWQbw==} + + '@zag-js/color-picker@1.41.2': + resolution: {integrity: sha512-UynyJ/bTBeSlq6ziHr9GVrFycDjVxfLFIb8Aivu/XvihrAAvkhXUxwy+1ax+hj7peGlTY590xoQAvQixV+McBA==} + + '@zag-js/color-utils@1.41.2': + resolution: {integrity: sha512-Lsi5c2ztGZqud0oeDtAn3xFhgrYMaeaz1zi4p+mr0zXOuQNuZzfsrJ0stQ45s8L/xT8UJtGhYyEVy1+xjE4ARA==} + + '@zag-js/combobox@1.41.2': + resolution: {integrity: sha512-V9jQteyQHs8ZNQx/FcA98P1NVZas/yjiW1V7s4L+h8MnRS3AK97+FAHAT83KCiZ34/vkpLF6ZEHI2zkcQpNXcg==} + + '@zag-js/core@1.41.2': + resolution: {integrity: sha512-xXTN3zKwOtMI4+5dG2cG+T1B4WR3X9alXiYaPJKaGd4N2eYRj9JEPte3Hv9gtFm+RM0b9VIwksHEg25rqE4apw==} + + '@zag-js/date-input@1.41.2': + resolution: {integrity: sha512-J8PdpEpM9TBxZBEE8/Nxi39LNJOZY7lilTbgcDABR5uiviZUk5++5GSdUTspcjFUIXx5SvqmdCk2RF7hsUEHVQ==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/date-picker@1.41.2': + resolution: {integrity: sha512-j9LaznCV4QCfrq/y/mNAN9XgIRFFg+414TxGT4TK8mfWouIaFvxEoKdGP0l/LL4DFv1NRDaRdSBBcw3eXtMVnA==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/date-utils@1.41.2': + resolution: {integrity: sha512-jdcWLa5fYLTsvGWJkx4v/9Hzqf6UHdvJDeP6NxHpwoEmzYy7+AghYKBNSnRrJIBCQJgl1vM9OkpMvYrLNHr9jw==} + peerDependencies: + '@internationalized/date': '>=3.0.0' + + '@zag-js/dialog@1.41.2': + resolution: {integrity: sha512-t1N72snpFGiKj7cg2PivPdsy+X1YdgXPv7bzovpqjMLy0kN+gYTPoV1Iy/hQA+g021dz9XGgXzkDuU45sJqsFA==} + + '@zag-js/dismissable@1.41.2': + resolution: {integrity: sha512-hO/tFhRZ7S+LOOljGOQJIubbc3MXg41+iWR1yUXKl76cAenbxaCit1LZmUCwQPvRN0GndK6bDQo5ETjHZz/k7A==} + + '@zag-js/dom-query@1.41.2': + resolution: {integrity: sha512-+eBk1nlJA312mNmY/GSThLRwcCRqMIL+A1pLsWvTlQLQjmH1/UxoAuv6l2yvRCT33XmC8FBlBIKnXhOCpDvIZA==} + + '@zag-js/drawer@1.41.2': + resolution: {integrity: sha512-aJql6L0cfHd1wXbemfLcNnjqTA/CbwVgQdWEVn5Qci6zhy4UJXPQeBBfxfgPgEOAbW60gL/gaaXG3d9Vx6+6oA==} + + '@zag-js/editable@1.41.2': + resolution: {integrity: sha512-loTM1lrHBBqYfR8SJZrYayVdWHMpXCLVir+aDTQ8/d4bb/Gfl/L8CJNs3BRj3yt9zvLoWTLO+4LOY3hLyQSR2w==} + + '@zag-js/file-upload@1.41.2': + resolution: {integrity: sha512-WFwIaKvHpCUjyYMvp42VoydT1WIP5DhDlpmG/nrF4i0ro7pLGb1A4dvyBrAfGcozCB88yR1y1iZKPDY1I9/uUw==} + + '@zag-js/file-utils@1.41.2': + resolution: {integrity: sha512-Ih+8ULbId0M+CFR4IsqG5y/0VLCk2l+1rgPH+21L40dlSB6z6qKSP2tG7W69Cj2/3vryZsn67ibn26iCPG/vOw==} + + '@zag-js/floating-panel@1.41.2': + resolution: {integrity: sha512-nJP3oZ4YrJh+7H5YdwNccSzPGXFqjQN1ujZ/xGDhegjz4XtL48QMFnAasxlRI8VGjse9Tj8VXlQZxXPrASGzlA==} + + '@zag-js/focus-trap@1.41.2': + resolution: {integrity: sha512-3QTtGUjFU2OLbyrDlyoYWvKZecCmtn/+bsfsHW159jJHiEGHVYK6CY8AI1ePsMk9gVay48bXh008j+lVli7gAw==} + + '@zag-js/focus-visible@1.41.2': + resolution: {integrity: sha512-oRjwtgafUdGVwLJUN6mKsnBQbez/CHYAPkPg1FxOnr5GFpEpr8oMTOZJ3wdPM2U1ynS9QnUUu/sXc3KQv/jX0Q==} + + '@zag-js/highlight-word@1.41.2': + resolution: {integrity: sha512-95zcZKqNrL7JlqAckfzHa+LRbnfoz1lj6skUhq/uHSnni1vH6+8fNWT8ruo9G7vpGopbyRmmcie5p41SbAwQjQ==} + + '@zag-js/hover-card@1.41.2': + resolution: {integrity: sha512-Xn9RVzgTkKaVzyJTDdBJiXmCleNi1/hmW8z73tC0vOiQvSSvPejg/JkzqTOLFODvQHK3NOw54QHZvmjYp9Mubw==} + + '@zag-js/i18n-utils@1.41.2': + resolution: {integrity: sha512-f1xqaEY79awBxgUyjFso0UEpIoEHZq+zRvB0nUVFHJRW7Ds/QhaFHKSRnf2QBTlP/ObvCT225R+piNAAc17Aaw==} + + '@zag-js/image-cropper@1.41.2': + resolution: {integrity: sha512-750aT4U+J/TJw/Z1QVoLU9JG0luCtf9CyvShtJFIxeS+i25aUoBI9pOKgSFABsOutIqNJTPq4gYpqtuxFjSxRw==} + + '@zag-js/interact-outside@1.41.2': + resolution: {integrity: sha512-dM4Fn9iyqQeqkCMRYZP+bAgWEPKRVQRqMmcPsN0OXBhhFKC31Em15LTIZXaOtVKAjH+iwx+UvSYFRiWwEjkOEA==} + + '@zag-js/json-tree-utils@1.41.2': + resolution: {integrity: sha512-gNaOzsbCwmTd2HM3/u0xQdWX5UDBfl8tCXFavzbamkFH0iYQOXJb7cqUXBVuI4KScIbHPCKwrzZjqA5Sg9qzAQ==} + + '@zag-js/listbox@1.41.2': + resolution: {integrity: sha512-iDGrZleP3ui2Q6Jgmr9RYlbd7njdJHs8Qb3IJrSIZBIeYyYmFvVUfAFjk0g/z/amjTx6uYxRASWSPy/RETd4ug==} + + '@zag-js/live-region@1.41.2': + resolution: {integrity: sha512-7ubIW5AQt1wx9S/gFN+rU4TyvuFWJrL/DhnDWPNlH5g3luDVHSNeYGeeqf4d4tkcibtpYZa0pg9CbXxRxrfwVA==} + + '@zag-js/marquee@1.41.2': + resolution: {integrity: sha512-cT77aMhrtAK3oe2O6+X3TLtQs8wupdPUtZgHMWVxCcQOwagrk7RUBEQwU3Cx7cTswpBdTKSuDYgUggfgCs96wg==} + + '@zag-js/menu@1.41.2': + resolution: {integrity: sha512-wwix8hcAUSi0scpWXCiDppfdZV01Za8nN0gqLt9GdhCiVSlr0rs9pK1ROgPKJTyc43UZfyFPqtTWVHvEHMM70w==} + + '@zag-js/navigation-menu@1.41.2': + resolution: {integrity: sha512-aROB9CHzskZpnoFuGFkp7dbkZdsXvp2nxQgsgld02I1sDqiwcQd+YMdB0/6Ik0oz6X8I70RKdUuQM9WQFQfDnA==} + + '@zag-js/number-input@1.41.2': + resolution: {integrity: sha512-QoQWCiVHO+ciUbq8uL8Kxhtk4o3UpwzYpJkfsOfitzsZoYpPc7V/A6+n5yABV2SOwpqBODwNASZdxiEa60kfow==} + + '@zag-js/pagination@1.41.2': + resolution: {integrity: sha512-vZTxz4DrIDfedMcTjDeEkOc1iXD9wkP8eKuEcDieHOodnjSnNNwtmoFw5DCWv+yEa/TByIamXBZ8ZxHY144JgA==} + + '@zag-js/password-input@1.41.2': + resolution: {integrity: sha512-OWKFl0S12Qnlf4R4WbMCJ/YU0kGfezm5tP0UiyubMO/Fixv6H0twDFaJSPg2F6POv5uomCcGubc1H7gO+fIhsQ==} + + '@zag-js/pin-input@1.41.2': + resolution: {integrity: sha512-Wrn3YDbmWL3qvUIzN4QyLO7PzEhunX7z8DwBpmrK04p7HxR9pniTLVIPk27xk2MzyAPYcl4mwd9/Mc88tBxHsg==} + + '@zag-js/popover@1.41.2': + resolution: {integrity: sha512-h/LlVMIERM+NWzYV0ZHURlJuaqkT8XxwyEV96XfVzjknDRFNoPSl5IttVYtoaPoUqC0p/Y4oTSiee4mUZKOLHw==} + + '@zag-js/popper@1.41.2': + resolution: {integrity: sha512-Iz4D5YAIiIPn4IHGjhX3QatR/RyGaDt43lBSZv0RYcPQYtFg9sUuek3wizjW9qXgdvItevvNMqRdpl7f3es09g==} + + '@zag-js/presence@1.41.2': + resolution: {integrity: sha512-OhOLPAf/DYPmgoEntrlrf3LOrkwA+Y0J3K03NXHXPnkRB7h8jyIbHqzHS0jRTb1pvsO2P/yowRyoYtptY2Zmog==} + + '@zag-js/progress@1.41.2': + resolution: {integrity: sha512-SnzrqN+Z568NoO4rrgjrIc/S4EXMEne5CDgWwt2kQ8yq9VysdH8TtHAjyciFRIio7cdgEZNHKw9jSccpMWgRwA==} + + '@zag-js/qr-code@1.41.2': + resolution: {integrity: sha512-+JLswCNnzf58aQTaX0SMUA9wRC8Hb/a/ZveJIXTz6853Siemay2HqOqB2WQIeF33HNldUFD/a4+4Q8ugg93ubA==} + + '@zag-js/radio-group@1.41.2': + resolution: {integrity: sha512-EZjos61jKHZlNw8ez80vG2T9gUwpooxcVpYOKS2hyhuEXn3wEoefS5v1WFbmpoA/8TUpUQnYxisAeNuQfEQCuQ==} + + '@zag-js/rating-group@1.41.2': + resolution: {integrity: sha512-SbJP4HiK5XRy/oC3xRowjZOCThq1n/QA2Z/XAkvKLJArVQCYjrH3MoWwWvMVNfNC5+ZJluborR2AGF7tlVLzxA==} + + '@zag-js/react@1.41.2': + resolution: {integrity: sha512-5Bx7mQAron4LFWI8Hhs/uw5kwQ19s2Tn30HhctozLqmCu4nnJSTSh7GRvX+uwRZnztGXBXoOrgBWIepU4RXFGg==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@zag-js/rect-utils@1.41.2': + resolution: {integrity: sha512-GWBTamaMLMG1p7Fe6V0dsXeTEmk+tqG3ciovzmjxURCJ3Yq2EkAMRhS0v5DG0oo+PyrPEIzEWukGBQkh/XRgYQ==} + + '@zag-js/remove-scroll@1.41.2': + resolution: {integrity: sha512-ieIrOgPKlCikAGEBIboQJoU7oxrL5BEY670tDOu7Eg7rNOdAwGXLEKPX2A/q+lREhOiVYjx0D3//vHTvdte80Q==} + + '@zag-js/scroll-area@1.41.2': + resolution: {integrity: sha512-hJFAwfIFuS7XmNsS3nB5rPm9OWXfB4d98sID7fjurcaZtDH5LqFriqfXhceNvs7rz7K4f/u8rufXrb6tcvATIA==} + + '@zag-js/scroll-snap@1.41.2': + resolution: {integrity: sha512-+70Al6LSASyEZtFyyffUJlDbE88KgYJDud05z8oZTqyEOLlTqnlSNkXq/P3siO7r3sNykg9H+TmAKn+/dVSKuw==} + + '@zag-js/select@1.41.2': + resolution: {integrity: sha512-3wGaKABILexoNBJ1bJiHqLLTctR/VMZaNA4cyKiqZeBEWtAkdMhgyY2xKobrP6KtUTqAeUFNVSTu4yCDrAQnUw==} + + '@zag-js/signature-pad@1.41.2': + resolution: {integrity: sha512-iNrOxY4gtqhsZdXYvlhF7s+LiOvwV7/kBpNnq8tJ1oYhgTs8wvKtJHrP86/CR05irEXQTaLfmeAJZuBEys9iRA==} + + '@zag-js/slider@1.41.2': + resolution: {integrity: sha512-mKK2BwoDbIGxAdkdKkPZJA1SHtEQt3lS9hJ6WghefYU2vyd0BXoIKvcDV3xJOzly5LXYhH5cJITn6JtGK8353A==} + + '@zag-js/splitter@1.41.2': + resolution: {integrity: sha512-Ubp4hkmzvVysU31jCINYbBXVqruu7rEPGqugWMzeXC5Hwda648aHpbg4Jix/wRtaGLjsyh6KOVEwAmoJU9NwhQ==} + + '@zag-js/steps@1.41.2': + resolution: {integrity: sha512-m0t2r8+FWwa2b2aU5JiNrHVdYHyNZYHK0G3Tq9lCOSQoDeoJIkyta+sIVehLVSY+0Ba9kOlkRUmYLbsnfXaW+Q==} + + '@zag-js/store@1.41.2': + resolution: {integrity: sha512-dVZF7E1ezXzynrKhMH3rfSr2rBbCfvTjvXbXz7//1PNULuq58UU5dG93V+9l834npCZxI2+PrpY45wZLJPTsIA==} + + '@zag-js/switch@1.41.2': + resolution: {integrity: sha512-qHbQK95UUHN0tj+bf9LLphLcMo/uTg2Pvs0c1Gs03Zh4g3NtHf0uYIhMZKYlCH0hNVlKrmdzLKWgeoDxv9gySw==} + + '@zag-js/tabs@1.41.2': + resolution: {integrity: sha512-7YVj2mCcxRbn1wMXP9anaTOVf+J0fa5uaPScr8e4+e2xc+/1WKzqN6V8IDeKS5wV/xzi1r3Ny307sX7Xz4ZJVQ==} + + '@zag-js/tags-input@1.41.2': + resolution: {integrity: sha512-aIPEndSO+9LHxyoXLUr0Uttxw2cYMyDuii5w50wn/N7lFJD49U3Sj+6XaB/oJSbDAwn10WrsRDtb7Gs2kmU+Qw==} + + '@zag-js/timer@1.41.2': + resolution: {integrity: sha512-PRYLWaip0+1FeVGEMNk5wMGAAIYgBIWwulZ4U5I+2Ayjohzp2NUAfwJ3sqoYvRrfjNYUNAPdU4eGu1zetC+oVQ==} + + '@zag-js/toast@1.41.2': + resolution: {integrity: sha512-+F3PsAo6EIz4rh73IOMCb/+FOUp7e3VjUY2q5sdU4IbfOzJBIbVSJxn0PQmHkuxkzWdCom0Lv0qSPFg/UTplnQ==} + + '@zag-js/toggle-group@1.41.2': + resolution: {integrity: sha512-C6wn3A89h24hTs0BN9ryEuKatfR493u7QqxS06TeK9oI/KZBvm5Rwm8FPHSUJvsUTkAkow4PsXC0Ra19duzEdQ==} + + '@zag-js/toggle@1.41.2': + resolution: {integrity: sha512-EFB9pb3pEtwXt7RSivVLWXV64dKUF8gsn75tt8TbYBgfy+zW85MsRLu8U48TXZN5teQWAKKNujr9bxQsW/CWvQ==} + + '@zag-js/tooltip@1.41.2': + resolution: {integrity: sha512-68okWJCFXfW8r0h97kEcU2yKPkq6e0S6QkiYh09ifMXoYjrQw/shPol8PgrS1poqKJigUWtsKm+bw73abhMn3w==} + + '@zag-js/tour@1.41.2': + resolution: {integrity: sha512-Q7UsvuHYYBo1Cs4b4OS0e/D8lxd2GpSRII93s5BQPi5HTcBjaWhVysAykmCFbat6Z56z0NBmFHNdhZ8oVPls1A==} + + '@zag-js/tree-view@1.41.2': + resolution: {integrity: sha512-QNi0VpV+RyzF4NP72+kSleUpauF9SMAzOAe59nxvs8jAUHqV5diDCInnbQos4+cyFDXFzGq3lElot26A1yI+7Q==} + + '@zag-js/types@1.41.2': + resolution: {integrity: sha512-L6CNvK06lIVpy0X8eG3kbDIx8Uuv+3KHElxXYSzRXSJ7/OLCv1sTRgEvnxNtdIWOrksGgxF4JtT7PXtoClGqNQ==} + + '@zag-js/utils@1.41.2': + resolution: {integrity: sha512-Yj8FSrR7vGA6ahUhjrThfHAF+PM2Y1Yv2lkXkqZZd60mPBhixcot1+SHOfEMV63JimQcWrmQ8QbeYYMmF+ZpLQ==} + + ag-psd@31.0.1: + resolution: {integrity: sha512-/byWVGIrKv0Jqq3lleaRji2QdrCsAhbG4Ansc9nWkH1ghOBbujnhdlRXMfK2A750mInXbPLsgpHhrtfq9lLWkA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.40: + resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.4: + resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.379: + resolution: {integrity: sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==} + + engine.io-client@6.6.6: + resolution: {integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + i18next-http-backend@3.0.6: + resolution: {integrity: sha512-mBOqy8993jtqAoj6XaI1XeC/8/9v6EPS+681ziegrPvTB0DoaCY7PpTS0SpY56qLMoS4OI1TZEM2Zf59zNh05w==} + + i18next@25.10.10: + resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + isbot@5.1.44: + resolution: {integrity: sha512-PGEHtwMnKbZpeSEXW2Utx+/JWed7dp6DiH0WWg33vGSDA7RUvpUeJSVlLrVkQ1RCpvDOUc/eH9ql7VsdbBZ8pA==} + engines: {node: '>=18'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.21.0: + resolution: {integrity: sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-releases@2.0.50: + resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} + engines: {node: '>=18'} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + oxfmt@0.56.0: + resolution: {integrity: sha512-9Dv0wV3zKiyvhjD7bRKaInKmHQ1sPx3RGOjQkGFJbbdQ16576yf8qhMSO9Q9cvHcs+1NpBsRTkuDDYFFPTJ6gw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint@1.71.0: + resolution: {integrity: sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-freehand@1.2.3: + resolution: {integrity: sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + proxy-compare@3.0.1: + resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==} + + proxy-memoize@3.0.1: + resolution: {integrity: sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-hook-tanstack-virtual@0.0.4: + resolution: {integrity: sha512-N6OnfdklUygY6K9Ik7SGZlAlc3uVOVm8kp3OP7d4m+0ELGgABm4W/f8kJPcda8CkgQUkFvDO8t8lOzbEsdJG3w==} + peerDependencies: + '@tanstack/react-virtual': ^3.14.2 + '@tanstack/virtual-core': ^3.17.0 + react: ^19.2.0 + react-dom: ^19.2.0 + + react-i18next@16.6.6: + resolution: {integrity: sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==} + peerDependencies: + i18next: '>= 25.10.9' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-icons@5.6.0: + resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==} + peerDependencies: + react: '*' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + rolldown@1.1.3: + resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + engines: {node: '>=10'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.6: + resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + engines: {node: '>=10.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinykeys@4.0.0: + resolution: {integrity: sha512-z5bQRQHL1PSx3lGOZEAMM2oKp0EBtn5T5f7HJnaKPOzk6vEH0wUPvdHLeQ7F4tmkek8AgoTQISUFmn/5SNF2xA==} + engines: {node: '>=22'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@8.1.0: + resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + +snapshots: + + '@ark-ui/react@5.37.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@internationalized/date': 3.12.2 + '@zag-js/accordion': 1.41.2 + '@zag-js/anatomy': 1.41.2 + '@zag-js/angle-slider': 1.41.2 + '@zag-js/async-list': 1.41.2 + '@zag-js/auto-resize': 1.41.2 + '@zag-js/avatar': 1.41.2 + '@zag-js/carousel': 1.41.2 + '@zag-js/cascade-select': 1.41.2 + '@zag-js/checkbox': 1.41.2 + '@zag-js/clipboard': 1.41.2 + '@zag-js/collapsible': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/color-picker': 1.41.2 + '@zag-js/color-utils': 1.41.2 + '@zag-js/combobox': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/date-input': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/date-picker': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/date-utils': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/dialog': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/drawer': 1.41.2 + '@zag-js/editable': 1.41.2 + '@zag-js/file-upload': 1.41.2 + '@zag-js/file-utils': 1.41.2 + '@zag-js/floating-panel': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/highlight-word': 1.41.2 + '@zag-js/hover-card': 1.41.2 + '@zag-js/i18n-utils': 1.41.2 + '@zag-js/image-cropper': 1.41.2 + '@zag-js/json-tree-utils': 1.41.2 + '@zag-js/listbox': 1.41.2 + '@zag-js/marquee': 1.41.2 + '@zag-js/menu': 1.41.2 + '@zag-js/navigation-menu': 1.41.2 + '@zag-js/number-input': 1.41.2 + '@zag-js/pagination': 1.41.2 + '@zag-js/password-input': 1.41.2 + '@zag-js/pin-input': 1.41.2 + '@zag-js/popover': 1.41.2 + '@zag-js/presence': 1.41.2 + '@zag-js/progress': 1.41.2 + '@zag-js/qr-code': 1.41.2 + '@zag-js/radio-group': 1.41.2 + '@zag-js/rating-group': 1.41.2 + '@zag-js/react': 1.41.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@zag-js/scroll-area': 1.41.2 + '@zag-js/select': 1.41.2 + '@zag-js/signature-pad': 1.41.2 + '@zag-js/slider': 1.41.2 + '@zag-js/splitter': 1.41.2 + '@zag-js/steps': 1.41.2 + '@zag-js/switch': 1.41.2 + '@zag-js/tabs': 1.41.2 + '@zag-js/tags-input': 1.41.2 + '@zag-js/timer': 1.41.2 + '@zag-js/toast': 1.41.2 + '@zag-js/toggle': 1.41.2 + '@zag-js/toggle-group': 1.41.2 + '@zag-js/tooltip': 1.41.2 + '@zag-js/tour': 1.41.2 + '@zag-js/tree-view': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@blazediff/core@1.9.1': {} + + '@chakra-ui/react@3.36.0(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@ark-ui/react': 5.37.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@emotion/is-prop-valid': 1.4.0 + '@emotion/react': 11.14.0(@types/react@19.2.17)(react@19.2.7) + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.7) + '@emotion/utils': 1.4.2 + '@pandacss/is-valid-prop': 1.11.3 + csstype: 3.2.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@dnd-kit/accessibility@3.1.1(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tslib: 2.8.1 + + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.29.7 + '@babel/runtime': 7.29.7 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.7) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.2.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@fontsource/inter@5.2.8': {} + + '@internationalized/date@3.12.2': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.6': + dependencies: + '@swc/helpers': 0.5.23 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.137.0': {} + + '@oxfmt/binding-android-arm-eabi@0.56.0': + optional: true + + '@oxfmt/binding-android-arm64@0.56.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.56.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.56.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.56.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.56.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.56.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.56.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.56.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.56.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.56.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.56.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.56.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.56.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.56.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.56.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.56.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.56.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.56.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.71.0': + optional: true + + '@oxlint/binding-android-arm64@1.71.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.71.0': + optional: true + + '@oxlint/binding-darwin-x64@1.71.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.71.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.71.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.71.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.71.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.71.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.71.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.71.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.71.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.71.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.71.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.71.0': + optional: true + + '@pandacss/is-valid-prop@1.11.3': {} + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/binding-android-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-x64@1.1.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.3': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.3': + optional: true + + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.1.3)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7))': + dependencies: + '@babel/core': 7.29.7 + picomatch: 4.0.4 + rolldown: 1.1.3 + optionalDependencies: + '@babel/runtime': 7.29.7 + vite: 8.1.0(@types/node@26.0.1)(esbuild@0.27.7) + + '@rolldown/pluginutils@1.0.1': {} + + '@socket.io/component-emitter@3.1.2': {} + + '@standard-schema/spec@1.1.0': {} + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@tanstack/history@1.162.0': {} + + '@tanstack/react-router@1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/history': 1.162.0 + '@tanstack/react-store': 0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/router-core': 1.171.13 + isbot: 5.1.44 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/react-store@0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + + '@tanstack/react-virtual@3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/virtual-core': 3.17.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/router-core@1.171.13': + dependencies: + '@tanstack/history': 1.162.0 + cookie-es: 3.1.1 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + + '@tanstack/store@0.9.3': {} + + '@tanstack/virtual-core@3.17.1': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.0.1': + dependencies: + undici-types: 8.3.0 + + '@types/parse-json@4.0.2': {} + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/use-sync-external-store@1.5.0': {} + + '@vitejs/plugin-react@6.0.3(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.1.3)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.0(@types/node@26.0.1)(esbuild@0.27.7) + optionalDependencies: + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.1.3)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)) + babel-plugin-react-compiler: 1.0.0 + + '@vitest/browser-playwright@4.1.9(playwright@1.61.1)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7))(vitest@4.1.9)': + dependencies: + '@vitest/browser': 4.1.9(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7))(vitest@4.1.9) + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)) + playwright: 1.61.1 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@26.0.1)(@vitest/browser-playwright@4.1.9)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.9(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7))(vitest@4.1.9)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)) + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@26.0.1)(@vitest/browser-playwright@4.1.9)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.0(@types/node@26.0.1)(esbuild@0.27.7) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@xyflow/react@12.11.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@xyflow/system': 0.0.78 + classcat: 5.0.5 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + zustand: 4.5.7(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.78': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + + '@zag-js/accordion@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/anatomy@1.41.2': {} + + '@zag-js/angle-slider@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/rect-utils': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/aria-hidden@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/async-list@1.41.2': + dependencies: + '@zag-js/core': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/auto-resize@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/avatar@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/carousel@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/scroll-snap': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/cascade-select@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/rect-utils': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/checkbox@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/clipboard@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/collapsible@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/collection@1.41.2': + dependencies: + '@zag-js/utils': 1.41.2 + + '@zag-js/color-picker@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/color-utils': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/color-utils@1.41.2': + dependencies: + '@zag-js/utils': 1.41.2 + + '@zag-js/combobox@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/live-region': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/core@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/date-input@1.41.2(@internationalized/date@3.12.2)': + dependencies: + '@internationalized/date': 3.12.2 + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/date-utils': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/dom-query': 1.41.2 + '@zag-js/live-region': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/date-picker@1.41.2(@internationalized/date@3.12.2)': + dependencies: + '@internationalized/date': 3.12.2 + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/date-utils': 1.41.2(@internationalized/date@3.12.2) + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/live-region': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/date-utils@1.41.2(@internationalized/date@3.12.2)': + dependencies: + '@internationalized/date': 3.12.2 + + '@zag-js/dialog@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/aria-hidden': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/remove-scroll': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/dismissable@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + '@zag-js/interact-outside': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/dom-query@1.41.2': + dependencies: + '@zag-js/types': 1.41.2 + + '@zag-js/drawer@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/aria-hidden': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/remove-scroll': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/editable@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/interact-outside': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/file-upload@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/file-utils': 1.41.2 + '@zag-js/i18n-utils': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/file-utils@1.41.2': + dependencies: + '@zag-js/i18n-utils': 1.41.2 + + '@zag-js/floating-panel@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/rect-utils': 1.41.2 + '@zag-js/store': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/focus-trap@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/focus-visible@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/highlight-word@1.41.2': {} + + '@zag-js/hover-card@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/i18n-utils@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/image-cropper@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/interact-outside@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/json-tree-utils@1.41.2': {} + + '@zag-js/listbox@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/live-region@1.41.2': {} + + '@zag-js/marquee@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/menu@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/rect-utils': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/navigation-menu@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/number-input@1.41.2': + dependencies: + '@internationalized/number': 3.6.6 + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/pagination@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/password-input@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/pin-input@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/popover@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/aria-hidden': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/remove-scroll': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/popper@1.41.2': + dependencies: + '@floating-ui/dom': 1.7.6 + '@zag-js/dom-query': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/presence@1.41.2': + dependencies: + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + + '@zag-js/progress@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/qr-code@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + proxy-memoize: 3.0.1 + uqr: 0.1.3 + + '@zag-js/radio-group@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/rating-group@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/react@1.41.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@zag-js/core': 1.41.2 + '@zag-js/store': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@zag-js/rect-utils@1.41.2': {} + + '@zag-js/remove-scroll@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/scroll-area@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/scroll-snap@1.41.2': + dependencies: + '@zag-js/dom-query': 1.41.2 + + '@zag-js/select@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/signature-pad@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + perfect-freehand: 1.2.3 + + '@zag-js/slider@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/splitter@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/steps@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/store@1.41.2': + dependencies: + proxy-compare: 3.0.1 + + '@zag-js/switch@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tabs@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tags-input@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/auto-resize': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/interact-outside': 1.41.2 + '@zag-js/live-region': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/timer@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/toast@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/toggle-group@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/toggle@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tooltip@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-visible': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tour@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dismissable': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/focus-trap': 1.41.2 + '@zag-js/interact-outside': 1.41.2 + '@zag-js/popper': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/tree-view@1.41.2': + dependencies: + '@zag-js/anatomy': 1.41.2 + '@zag-js/collection': 1.41.2 + '@zag-js/core': 1.41.2 + '@zag-js/dom-query': 1.41.2 + '@zag-js/types': 1.41.2 + '@zag-js/utils': 1.41.2 + + '@zag-js/types@1.41.2': + dependencies: + csstype: 3.2.3 + + '@zag-js/utils@1.41.2': {} + + ag-psd@31.0.1: + dependencies: + base64-js: 1.5.1 + pako: 2.1.0 + + assertion-error@2.0.1: {} + + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.29.7 + cosmiconfig: 7.1.0 + resolve: 1.22.12 + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.7 + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.40: {} + + browserslist@4.28.4: + dependencies: + baseline-browser-mapping: 2.10.40 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.379 + node-releases: 2.0.50 + update-browserslist-db: 1.2.3(browserslist@4.28.4) + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001799: {} + + chai@6.2.2: {} + + classcat@5.0.5: {} + + convert-source-map@1.9.0: {} + + convert-source-map@2.0.0: {} + + cookie-es@3.1.1: {} + + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.3 + + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + csstype@3.2.3: {} + + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.379: {} + + engine.io-client@6.6.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.0 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-errors@1.3.0: {} + + es-module-lexer@2.1.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + optional: true + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + find-root@1.1.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + i18next-http-backend@3.0.6: + dependencies: + cross-fetch: 4.1.0 + transitivePeerDependencies: + - encoding + + i18next@25.10.10(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.7 + optionalDependencies: + typescript: 6.0.3 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + is-arrayish@0.2.1: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + isbot@5.1.44: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.21.0(react@19.2.7): + dependencies: + react: 19.2.7 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-releases@2.0.50: {} + + obug@2.1.3: {} + + oxfmt@0.56.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.56.0 + '@oxfmt/binding-android-arm64': 0.56.0 + '@oxfmt/binding-darwin-arm64': 0.56.0 + '@oxfmt/binding-darwin-x64': 0.56.0 + '@oxfmt/binding-freebsd-x64': 0.56.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.56.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.56.0 + '@oxfmt/binding-linux-arm64-gnu': 0.56.0 + '@oxfmt/binding-linux-arm64-musl': 0.56.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.56.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.56.0 + '@oxfmt/binding-linux-riscv64-musl': 0.56.0 + '@oxfmt/binding-linux-s390x-gnu': 0.56.0 + '@oxfmt/binding-linux-x64-gnu': 0.56.0 + '@oxfmt/binding-linux-x64-musl': 0.56.0 + '@oxfmt/binding-openharmony-arm64': 0.56.0 + '@oxfmt/binding-win32-arm64-msvc': 0.56.0 + '@oxfmt/binding-win32-ia32-msvc': 0.56.0 + '@oxfmt/binding-win32-x64-msvc': 0.56.0 + + oxlint@1.71.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.71.0 + '@oxlint/binding-android-arm64': 1.71.0 + '@oxlint/binding-darwin-arm64': 1.71.0 + '@oxlint/binding-darwin-x64': 1.71.0 + '@oxlint/binding-freebsd-x64': 1.71.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.71.0 + '@oxlint/binding-linux-arm-musleabihf': 1.71.0 + '@oxlint/binding-linux-arm64-gnu': 1.71.0 + '@oxlint/binding-linux-arm64-musl': 1.71.0 + '@oxlint/binding-linux-ppc64-gnu': 1.71.0 + '@oxlint/binding-linux-riscv64-gnu': 1.71.0 + '@oxlint/binding-linux-riscv64-musl': 1.71.0 + '@oxlint/binding-linux-s390x-gnu': 1.71.0 + '@oxlint/binding-linux-x64-gnu': 1.71.0 + '@oxlint/binding-linux-x64-musl': 1.71.0 + '@oxlint/binding-openharmony-arm64': 1.71.0 + '@oxlint/binding-win32-arm64-msvc': 1.71.0 + '@oxlint/binding-win32-ia32-msvc': 1.71.0 + '@oxlint/binding-win32-x64-msvc': 1.71.0 + + pako@2.1.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + perfect-freehand@1.2.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + + pngjs@7.0.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-compare@3.0.1: {} + + proxy-memoize@3.0.1: + dependencies: + proxy-compare: 3.0.1 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-hook-tanstack-virtual@0.0.4(@tanstack/react-virtual@3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/virtual-core@3.17.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@tanstack/react-virtual': 3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/virtual-core': 3.17.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react-i18next@16.6.6(i18next@25.10.10(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 25.10.10(typescript@6.0.3) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + typescript: 6.0.3 + + react-icons@5.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + react-is@16.13.1: {} + + react@19.2.7: {} + + resolve-from@4.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rolldown@1.1.3: + dependencies: + '@oxc-project/types': 0.137.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.3 + '@rolldown/binding-darwin-arm64': 1.1.3 + '@rolldown/binding-darwin-x64': 1.1.3 + '@rolldown/binding-freebsd-x64': 1.1.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 + '@rolldown/binding-linux-arm64-gnu': 1.1.3 + '@rolldown/binding-linux-arm64-musl': 1.1.3 + '@rolldown/binding-linux-ppc64-gnu': 1.1.3 + '@rolldown/binding-linux-s390x-gnu': 1.1.3 + '@rolldown/binding-linux-x64-gnu': 1.1.3 + '@rolldown/binding-linux-x64-musl': 1.1.3 + '@rolldown/binding-openharmony-arm64': 1.1.3 + '@rolldown/binding-wasm32-wasi': 1.1.3 + '@rolldown/binding-win32-arm64-msvc': 1.1.3 + '@rolldown/binding-win32-x64-msvc': 1.1.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + seroval-plugins@1.5.4(seroval@1.5.4): + dependencies: + seroval: 1.5.4 + + seroval@1.5.4: {} + + siginfo@2.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + engine.io-client: 6.6.6 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + source-map-js@1.2.1: {} + + source-map@0.5.7: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + stylis@4.2.0: {} + + supports-preserve-symlinks-flag@1.0.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinykeys@4.0.0: {} + + tinypool@2.1.0: {} + + tinyrainbow@3.1.0: {} + + totalist@3.0.1: {} + + tr46@0.0.3: {} + + tslib@2.8.1: {} + + typescript@6.0.3: {} + + undici-types@8.3.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.4): + dependencies: + browserslist: 4.28.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uqr@0.1.3: {} + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.1.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.0.1 + esbuild: 0.27.7 + fsevents: 2.3.3 + + vitest@4.1.9(@types/node@26.0.1)(@vitest/browser-playwright@4.1.9)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.0(@types/node@26.0.1)(esbuild@0.27.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.0.1 + '@vitest/browser-playwright': 4.1.9(playwright@1.61.1)(vite@8.1.0(@types/node@26.0.1)(esbuild@0.27.7))(vitest@4.1.9) + transitivePeerDependencies: + - msw + + void-elements@3.1.0: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.0: {} + + xmlhttprequest-ssl@2.1.2: {} + + yallist@3.1.1: {} + + yaml@1.10.3: {} + + zod@4.4.3: {} + + zustand@4.5.7(@types/react@19.2.17)(react@19.2.7): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 diff --git a/invokeai/frontend/webv2/public/locales/ar.json b/invokeai/frontend/webv2/public/locales/ar.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/ar.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/az.json b/invokeai/frontend/webv2/public/locales/az.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/az.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/bg.json b/invokeai/frontend/webv2/public/locales/bg.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/bg.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/de.json b/invokeai/frontend/webv2/public/locales/de.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/de.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/en-GB.json b/invokeai/frontend/webv2/public/locales/en-GB.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/en-GB.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json new file mode 100644 index 00000000000..844d3da2bd9 --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -0,0 +1,1654 @@ +{ + "common": { + "actions": "Actions", + "active": "Active", + "add": "Add", + "all": "All", + "apply": "Apply", + "archived": "Archived", + "asc": "Asc", + "assets": "Assets", + "cancel": "Cancel", + "cancelFailed": "Cancel failed", + "canceled": "Canceled", + "clear": "Clear", + "clearSearch": "Clear search", + "close": "Close", + "configure": "Configure", + "copy": "Copy", + "crop": "Crop", + "clipboardBlocked": "Clipboard access was blocked by the browser.", + "couldNotCopy": "Could not copy", + "countOfTotal": "{{count}} of {{total}}", + "created": "Created", + "delete": "Delete", + "desc": "Desc", + "discard": "Discard", + "discardAll": "Discard All", + "done": "Done", + "duplicate": "Duplicate", + "edit": "Edit", + "error": "Error", + "export": "Export", + "failed": "Failed", + "generating": "Generating", + "graph": "Graph", + "images": "Images", + "import": "Import", + "id": "ID", + "item": "Item", + "json": "JSON", + "lastSaved": "Last saved", + "loadingApplication": "Loading Application", + "negative": "Negative", + "name": "Name", + "nothingHereYet": "Nothing here yet.", + "none": "None", + "notYet": "Not yet", + "open": "Open", + "pageNumber": "Page {{page}}", + "pages": "Pages", + "previousPage": "Previous page", + "prompt": "Prompt", + "project": "Project", + "rename": "Rename", + "required": "Required", + "refresh": "Refresh", + "reload": "Reload", + "reset": "Reset", + "retry": "Retry", + "saved": "Saved", + "save": "Save", + "seed": "Seed", + "settings": "Settings", + "nextPage": "Next page", + "somethingWentWrong": "Something went wrong.", + "swap": "Swap", + "status": { + "canceled": "Canceled", + "completed": "Completed", + "failed": "Failed", + "pending": "Pending" + }, + "total": "Total", + "unknownTime": "Unknown time", + "view": "View", + "viewJson": "View JSON", + "widgetViewUnavailable": "Widget view unavailable." + }, + "app": { + "name": "Invoke AI", + "nameWithVersion": "{{name}} {{version}}" + }, + "auth": { + "account": "Account", + "accountSettings": "Account settings", + "accountUpdated": "Account updated", + "administrator": "Administrator", + "changePassword": "Change password", + "confirmNewPassword": "Confirm new password", + "confirmPassword": "Confirm password", + "couldNotCreateAdmin": "Could not create the administrator account.", + "couldNotUpdateAccount": "Could not update your account.", + "createAdminAccount": "Create administrator account", + "currentPassword": "Current password", + "displayNameHelp": "Optional — shown instead of your email.", + "hidePassword": "Hide password", + "keepSignedIn": "Keep me signed in for a week", + "leaveBlankKeepPassword": "Leave blank to keep your password.", + "password": "Password", + "passwordGeneratedDescription": "Reveal it with the eye icon and store it somewhere safe.", + "passwordStrength": { + "moderate": "Moderate", + "strong": "Strong", + "weak": "Weak" + }, + "profileDisplayNameHelp": "Shown instead of your email across the workspace.", + "sessionExpired": "Your session expired. Sign in again to continue.", + "setupFooter": "You can add more users later from the user menu.", + "setupSubtitle": "Create the administrator account to get started.", + "setupTitle": "Set up Invoke", + "showPassword": "Show password", + "signIn": "Sign in", + "signInFailed": "Sign-in failed. Check your email and password.", + "signInSubtitle": "Sign in to your workspace to continue.", + "signedInAs": "Signed in as {{email}}", + "signOut": "Sign out", + "welcomeTitle": "Welcome to Invoke", + "yourPassword": "Your password" + }, + "splash": { + "artworkBy": "Artwork by {{artist}}", + "copyright": "© {{year}} Invoke AI | All rights reserved", + "loadingApplication": "Loading Application", + "loadingWorkspace": "Loading workspace", + "openingProject": "Opening project", + "tagline": "Image Generation for Creatives" + }, + "notifications": { + "empty": "Successful operations, errors, and system messages appear here.", + "labelWithCount": "Notifications: {{label}}", + "newCount_one": "{{count}} new", + "newCount_other": "{{count}} new", + "totalCount_one": "{{count}} total", + "totalCount_other": "{{count}} total", + "kind": { + "error": "Error", + "info": "Info", + "success": "Success" + }, + "markRead": "Mark Read" + }, + "projects": { + "allSavedAlreadyOpen": "All saved projects are already open.", + "closeProjectLabel": "Close {{name}}", + "couldNotOpen": "Could not open project", + "couldNotOpenDescription": "\"{{name}}\" could not be loaded from the server.", + "createNewProject": "Create new project", + "deleteFailed": "Delete failed", + "deleteProject": "Delete project", + "deleteProjectQuestion": "Delete project?", + "deleteProjectCardBody": "Delete \"{{name}}\"? The project is removed from the server permanently.", + "deleteProjectTabBody": "Delete \"{{name}}\"? The project and its saved copy on the server are removed permanently. To keep it in your library, close the tab instead.", + "deleteProjectWithEllipsis": "Delete project…", + "duplicateFailed": "Duplicate failed", + "editedRelative": "Edited {{time}}", + "exportFailed": "Export failed", + "failedToLoad": "Failed to load your projects.", + "importFailed": "Import failed", + "importWithEllipsis": "Import…", + "newProject": "New project", + "noSavedProjects": "No saved projects yet.", + "openProject": "Open project", + "openProjectLabel": "Open {{name}}", + "projectDetails": "Project details", + "renameFailed": "Rename failed", + "renameWithEllipsis": "Rename…", + "projectDuplicated": "Project duplicated", + "projectDuplicatedDescription": "\"{{name}}\" was created." + }, + "users": { + "accountCount_one": "{{count}} account in this workspace.", + "accountCount_other": "{{count}} accounts in this workspace.", + "activeLabel": "{{name}} active", + "addUser": "Add user", + "admin": "Admin", + "adminOnly": "User management is available to administrators only.", + "administrator": "Administrator", + "administratorHelp": "Can manage users and all workspace settings.", + "backendRejectedChange": "The backend rejected the change.", + "backendRejectedRequest": "The backend rejected the request.", + "cannotChangeSelfRole": "You cannot change your own role.", + "cannotDeactivateSelf": "You cannot deactivate your own account.", + "cannotDeleteSelf": "You cannot delete your own account.", + "couldNotActivate": "Could not activate the user", + "couldNotCreate": "Could not create the user.", + "couldNotDeactivate": "Could not deactivate the user", + "couldNotDelete": "Could not delete the user", + "couldNotGeneratePassword": "Could not generate a password", + "couldNotLoad": "Could not load users.", + "couldNotUpdate": "Could not update the user.", + "createUser": "Create user", + "created": "User created", + "deleted": "User deleted", + "deleteBody": "Delete {{name}}? Their account is removed permanently.", + "deleteTitle": "Delete user?", + "deleteUser": "Delete user", + "deleteUserNamed": "Delete {{name}}", + "description": "Manage workspace accounts and roles.", + "displayName": "Display name", + "displayNameHelp": "Optional — shown instead of the email.", + "editUser": "Edit user", + "editUserNamed": "Edit {{name}}", + "email": "Email", + "generate": "Generate", + "lastSignIn": "Last sign-in", + "leaveBlankPassword": "Leave blank to keep the current password.", + "manageUsers": "Manage users", + "management": "User management", + "never": "Never", + "newPassword": "New password", + "password": "Password", + "passwordGenerated": "Password generated", + "passwordGeneratedDescription": "Reveal it with the eye icon and share it with the user securely.", + "role": "Role", + "saveChanges": "Save changes", + "thisUser": "this user", + "title": "Users", + "updated": "User updated", + "user": "User", + "you": "You" + }, + "graphPreview": { + "graphJsonLabel": "{{title}} graph JSON", + "inputCount_one": "{{count}} input", + "inputCount_other": "{{count}} inputs", + "invokeRoute": "Invoke {{route}}", + "noCompiledGraph": "No compiled graph is available for \"{{graphId}}\" yet.", + "nodes": "Nodes", + "title": "{{title}} Graph Preview" + }, + "hotkeys": { + "addHotkey": "Add hotkey", + "bindings": "Hotkey bindings", + "cancelEdit": "Cancel hotkey edit", + "categories": { + "app": "App", + "canvas": "Canvas", + "gallery": "Gallery", + "viewer": "Viewer", + "workflows": "Workflows" + }, + "conflictsWith": "Conflicts with {{title}}", + "custom": "Custom", + "deleteHotkey": "Delete hotkey", + "description": "Account-bound keybinds. Project-level overrides can layer on later without changing hotkey ids.", + "disable": "Disable", + "disabled": "Disabled", + "duplicateBinding": "Duplicate binding in this hotkey", + "pending": "Pending", + "pressKeys": "Press keys", + "resetAll": "Reset all", + "resetHotkey": "Reset hotkey", + "saveEdit": "Save hotkey edit", + "searchPlaceholder": "Search hotkeys", + "title": "Hotkeys" + }, + "models": { + "accessToken": "Access Token", + "accessTokenForDownload": "Access token for this download", + "accessTokenHelp": "Attached to the next Pull only. For saved HuggingFace, Civitai, and provider keys, use the API Keys tab.", + "accessTokenPlaceholder": "Only needed for protected sources", + "actions": "Model actions", + "addImage": "Add image", + "addModels": "Add Models", + "addModelsResults": "Add models results", + "addTriggerPhrase": "Add a trigger phrase...", + "allBases": "All bases", + "allModels": "All models", + "apiKey": "API key", + "apiKeyConfigured": "API key configured", + "apiKeyFor": "{{title}} API key", + "apiKeys": "API Keys", + "apiKeysDescription": "Keys are used when installing protected models and when generating with external API models.", + "base": "Base", + "baseArchitecture": "Base Architecture", + "baseUrlFor": "{{title}} base URL", + "backendDisconnectedProgressStale": "Backend disconnected. Progress may be stale.", + "bundleInstallQueued": "Bundle install queued", + "bundleInstallQueuedDescription_one": "{{name}}: {{count}} install queued.", + "bundleInstallQueuedDescription_other": "{{name}}: {{count}} installs queued.", + "bytePlusApiKeyPlaceholder": "API key from BytePlus console", + "bulkDeleteBody_one": "Delete {{count}} selected model? Database records are removed, and files inside the InvokeAI models directory are deleted.", + "bulkDeleteBody_other": "Delete {{count}} selected models? Database records are removed, and files inside the InvokeAI models directory are deleted.", + "bulkDeleteConfirm_one": "Delete {{count}} Model", + "bulkDeleteConfirm_other": "Delete {{count}} Models", + "bulkDeleteFailed": "Bulk delete failed", + "bulkDeletePartialDescription": "{{deleted}} deleted; {{failed}} failed: {{error}}", + "cacheEmptied": "Model cache emptied", + "cleanupOrphaned": "Clean up orphaned models…", + "clearSelection": "Clear selection", + "convert": "Convert", + "convertBody": "Convert \"{{name}}\" to the diffusers format in place? The original checkpoint file is replaced by a diffusers folder.", + "convertToDiffusers": "Convert to diffusers", + "couldNotLoad": "Could not load models", + "couldNotLoadInstallQueue": "Could not load the install queue", + "couldNotLoadStarterModels": "Could not load starter models", + "customizedForThisModel": "Customized for this model", + "customizeDefaultField": "Customize {{field}} for this model", + "civitaiKey": "Civitai key", + "civitaiKeyDescription": "Attached automatically when installing civitai.com models that require login. Stored in this browser only.", + "civitaiKeyPlaceholder": "32-character key from civitai.com/user/account", + "cancelAllInstalls": "Cancel all installs", + "cancelFailed": "Cancel failed", + "cancelInstall": "Cancel install", + "clearFinished": "Clear finished", + "defaultFieldInherited": { + "cfgRescale": "App default: 0 (off)", + "cfgScale": "App default: 7", + "guidance": "Uses the generator default", + "height": "App default: 1024", + "preprocessor": "Chosen automatically per image", + "scheduler": "App default: euler_a", + "steps": "App default: 30", + "vaePrecision": "App default: fp16", + "weight": "App default: 0.75", + "width": "App default: 1024" + }, + "defaultFields": { + "cfgRescale": "CFG Rescale", + "cfgScale": "CFG Scale", + "guidance": "Guidance", + "height": "Height", + "preprocessor": "Preprocessor", + "scheduler": "Scheduler", + "steps": "Steps", + "vaePrecision": "VAE Precision", + "weight": "Weight", + "width": "Width" + }, + "defaultSettings": "Default Settings", + "defaultSettingsHelp": "Applied automatically when this model is selected. Off = use the app default.", + "defaultSettingsSaved": "Default settings saved", + "deleteBody": "Delete \"{{name}}\"? The database record is removed, and the model files are deleted if they live inside the InvokeAI models directory.", + "deleteModel": "Delete model", + "deleted": "Models deleted", + "deletedDescription_one": "{{count}} model deleted.", + "deletedDescription_other": "{{count}} models deleted.", + "deleteSelectedTitle": "Delete selected models", + "deleteSelectedWithBytes": "Delete Selected ({{bytes}})", + "description": "Description", + "details": "Model Details", + "dismissResults": "Dismiss results", + "dismissScanResults": "Dismiss scan results", + "downloadProgress": "Download progress", + "editing": "Editing", + "emptyCache": "Empty model cache", + "failedToEmptyCache": "Failed to empty model cache", + "failedToLinkModels": "Failed to link models.", + "failedToLoadExternalProviders": "Failed to load external providers.", + "failedToLoadRelatedModels": "Failed to load related models.", + "failedToRemoveModelImage": "Failed to remove model image.", + "failedToSaveDefaults": "Failed to save default settings.", + "failedToScanOrphaned": "Failed to scan for orphaned models.", + "failedToUnlink": "Failed to unlink models.", + "failedToUpdateTriggerPhrases": "Failed to update trigger phrases.", + "failedToUploadModelImage": "Failed to upload model image.", + "fileNotFoundOnDisk": "The file for this model was not found on disk.", + "fileCount_one": "{{count}} file", + "fileCount_other": "{{count}} files", + "filesInRepo_one": "{{count}} file in {{repo}}", + "filesInRepo_other": "{{count}} files in {{repo}}", + "fileSize": "File Size", + "filterAndSortStarterModels": "Filter and sort starter models", + "filterFiles": "Filter files", + "filterAndSort": "Filter and sort models", + "filterResults": "Filter results", + "hash": "Hash", + "huggingFaceKeyDescription": "For gated repos like FLUX. Stored on the InvokeAI server and verified with HuggingFace.", + "huggingFaceRejectedToken": "HuggingFace rejected this token. Check that it has read access.", + "huggingFaceToken": "HuggingFace token", + "installAll": "Install all", + "installAllCount": "Install all ({{count}})", + "installFailed": "Install failed", + "installJobs": "Install jobs", + "installJobSummary_one": "{{count}} job · no active installs", + "installJobSummary_other": "{{count}} jobs · no active installs", + "installInPlace": "Install in place", + "installQueue": "Install Queue", + "installed": "Installed", + "installsDependencies_one": " (installs {{count}} dependency)", + "installsDependencies_other": " (installs {{count}} dependencies)", + "invalidDefaultSettings": "Invalid default settings.", + "invalidTriggerPhrase": "Invalid trigger phrase.", + "keyStatus": { + "configured": "Configured", + "invalid": "Invalid", + "saved": "Saved", + "unknown": "Not set", + "valid": "Valid" + }, + "library": "Model library", + "libraryMaintenance": "Model library maintenance", + "manager": "Model Manager", + "manageApiKeys": "Manage API keys", + "missingFilesCount": "Missing files ({{count}})", + "modelAndDependenciesQueued_one": "{{name}} and {{count}} dependency", + "modelAndDependenciesQueued_other": "{{name}} and {{count}} dependencies", + "modelCoverAlt": "{{name}} cover", + "modelImage": "Model image", + "modelImageUpdated": "Model image updated", + "modelInstallQueued": "Model install queued", + "modelManager": "Model manager", + "modelUpdated": "Model updated", + "modelType": "Model Type", + "noInstallableModelFiles": "This HuggingFace repo has no installable model files.", + "noInstallsDescription": "Model installs appear here while they download and import.", + "noInstallsYet": "No installs yet", + "noModelFilesFound": "No model files found in {{path}}.", + "noModelFilesFoundTitle": "No model files found", + "noRelatedModels": "No related models linked yet.", + "noStarterModelsPull": "No starter models match - press Pull to install the source you entered.", + "noStarterModelsSearch": "No starter models match your search.", + "noneInstalled": "No models installed", + "noneInstalledDescription": "Use Add Models to install your first model.", + "noneMatchFilters": "No models match your filters", + "noneMatchFiltersDescription": "Try clearing the search or filters.", + "noOrphaned": "No orphaned model folders found — your library is clean.", + "orphanedCleanup": "Orphaned model cleanup", + "orphanedCleanupFailed": "Orphaned model cleanup failed", + "orphanedCleanupPartialDescription": "{{deleted}} deleted, {{failed}} failed: {{error}}", + "orphanedDeletedDescription_one": "{{count}} orphaned model folder deleted.", + "orphanedDeletedDescription_other": "{{count}} orphaned model folders deleted.", + "orphanedDescription": "Folders in the models directory with no database record — usually leftovers from failed installs or external deletes.", + "orphanedTitle": "Orphaned Models", + "predictionType": "Prediction Type", + "overrideBaseUrl": "Override base URL", + "path": "Path", + "pauseAllDownloads": "Pause all downloads", + "pauseAllInstalls": "Pause all installs", + "pauseDownload": "Pause download", + "pauseFailed": "Pause failed", + "pauseInstall": "Pause install", + "plusMore": " +{{count}} more", + "pruneFailed": "Prune failed", + "press": "Press", + "pull": "Pull", + "queueActionFailed": "Queue action failed", + "refreshList": "Refresh model list", + "reidentify": "Re-identify model", + "relatedModels": "Related Models", + "relatedModelsHelp": "Link the models that pair well with this one - they are suggested together elsewhere in the app.", + "removeImage": "Remove image", + "removeModelImage": "Remove model image", + "removeTriggerPhrase": "Remove trigger phrase {{phrase}}", + "restartFile": "Restart file", + "restartFileFailed": "Restart file failed", + "restartThisFile": "Restart this file", + "resumeAllInstalls": "Resume all installs", + "resumeAllPausedDownloads": "Resume all paused downloads", + "resumeDownload": "Resume download", + "resumeFailed": "Resume failed", + "resumeInstall": "Resume install", + "resumeRequired": "Resume required", + "retryFailed": "Retry failed", + "retryFailedDownload": "Retry failed download", + "retryInstall": "Retry install", + "saveDefaults": "Save Defaults", + "scan": "Scan", + "scanFailed": "Scan failed", + "scanFolderTooltip": "Scan this folder for model files", + "scanSummary_one": "{{count}} file in {{path}} · {{notInstalled}} not installed", + "scanSummary_other": "{{count}} files in {{path}} · {{notInstalled}} not installed", + "searchModels": "Search models", + "searchModelsPlaceholder": "Search models…", + "searchCompatibleToLink": "Search compatible models to link...", + "searchOrAdd": "Search or add a model", + "searchOrAddPlaceholder": "Search models, or paste a URL, file path, or Hugging Face repo", + "selectAllCount": "Select all ({{count}})", + "selectedCount_one": "{{count}} selected", + "selectedCount_other": "{{count}} selected", + "selectModel": "Select a model", + "selectModelDescription": "Pick a model from the library to view details, edit metadata, set per-model defaults, and manage trigger phrases.", + "someCouldNotBeDeleted": "Some models could not be deleted", + "sortBase": "Base", + "sortDefault": "Default", + "sortFormat": "Format", + "sortName": "Name", + "sortSize": "Size", + "sort": { + "base": "Base", + "default": "Default", + "format": "Format", + "name": "Name", + "size": "Size" + }, + "sortBy": "Sort By", + "source": "Source", + "sourceUrl": "Source URL", + "sourceUrlHelp": "Model page or download URL.", + "title": "Models", + "toFindModelsInFolder": "to find models in this folder.", + "toInstallFrom": "to install from {{source}}.", + "type": "Type", + "triggerPhraseDuplicate": "That trigger phrase is already on this model.", + "triggerPhrases": "Trigger Phrases", + "triggerPhrasesHelp": "Phrases that activate this model, surfaced in the prompt editor.", + "unlink": "Unlink", + "unlinkNamed": "Unlink {{name}}", + "uploadCoverImageFor": "Upload cover image for {{name}}", + "useSupportedImage": "Use a PNG, JPEG, or WebP image.", + "variant": "Variant", + "variantHelp": "Architecture variant, e.g. inpaint or dev.", + "waitingForDownload": "Waiting for download" + }, + "nodes": { + "addCustomNodes": "Add custom nodes", + "addNodes": "Add Nodes", + "couldNotReloadCustomNodes": "Could not reload custom nodes.", + "couldNotLoadPacks": "Could not load custom node packs", + "customNodesReloaded": "Custom nodes reloaded", + "dependenciesRequired": "Dependencies required", + "dependenciesRequiredDescription": "{{name}} requires dependencies from {{dependencyFile}}. Install them, then restart InvokeAI.", + "details": "Node Pack Details", + "fieldName": "Field: {{name}}", + "fieldType": "Type: {{type}}", + "gitUrl": "Git URL", + "gitUrlHelp": "Paste a public Git repository URL. InvokeAI clones it into your custom nodes directory.", + "input": "Input", + "install": "Install", + "installActivity": "Custom node install activity", + "installFailed": "Install failed.", + "installing": "Installing", + "installedPacks": "Installed custom node packs", + "manager": "Nodes Manager", + "message": "Message", + "nodeCategory": "Category: {{category}}", + "nodeCount_one": "{{count}} node", + "nodeCount_other": "{{count}} nodes", + "nodePack": "node pack", + "nodePacks": "Node Packs", + "nodeType": "Type: {{type}}", + "nodeVersion": "Version: {{version}}", + "nodesDirectory": "Nodes Directory", + "nodesInPack": "Nodes in this pack", + "noPacks": "No custom node packs", + "noPacksDescription": "Install from a Git URL or place packs in your custom nodes directory, then reload.", + "noPacksMatch": "No packs match", + "noPreviews": "No node previews available", + "noPreviewsDescription": "The backend has no node definitions for this pack yet. Reload nodes or restart InvokeAI to load them.", + "noRecentActivity": "No recent activity", + "noRecentActivityDescription": "Install or uninstall a node pack and the outcome shows up here.", + "output": "Output", + "recentActivitySummary_one": "{{count}} recent activity", + "recentActivitySummary_other": "{{count}} recent activities", + "reloadFailed": "Reload failed", + "reloading": "Reloading", + "scanFolder": "Scan Folder", + "scanFolderDescription": "Add node packs directly to the custom nodes directory, then reload nodes. InvokeAI scans this folder on startup and reload.", + "searchPacks": "Search node packs", + "searchPacksPlaceholder": "Search packs…", + "selectPack": "Select a node pack", + "selectPackDescription": "Choose a pack from the library to see its path, manage it, and preview every node it adds.", + "status": "Status", + "trustWarning": "Custom nodes execute Python code. Only install packs from authors you trust.", + "tryDifferentSearch": "Try a different search.", + "uninstall": "Uninstall", + "uninstallBody": "Remove this pack from the custom nodes directory? A restart is required for removal to fully apply.", + "uninstallPack": "Uninstall Node Pack", + "uninstallTitle": "Uninstall {{name}}?", + "uninstalled": "Uninstalled" + }, + "launchpad": { + "projectsGreeting": "Welcome to Invoke", + "projectsGreetingWithName": "Welcome back, {{name}}", + "projectsSubtitle": "Pick up where you left off, or start something new.", + "sections": { + "models": "Models", + "nodes": "Nodes", + "projects": "Projects", + "users": "Users" + }, + "sectionsLabel": "Launchpad sections" + }, + "settings": { + "title": "Settings", + "descriptionGlobal": "Global preferences for this single-user install. Project settings apply only to the active project.", + "descriptionUser": "Preferences for the signed-in user. Project settings apply only to the active project.", + "enableHighlightFocusedRegions": "Highlight focused region", + "language": "Language", + "languageDescription": "Interface language. New workbench copy falls back to English until translators add coverage.", + "tabs": { + "appearance": "Appearance", + "behavior": "Behavior", + "developer": "Developer", + "hotkeys": "Hotkeys", + "project": "Project", + "queue": "Queue", + "workflow": "Workflow", + "workspace": "Workspace" + } + }, + "shell": { + "addCurrentLayout": "Add current layout...", + "builtInPresets": "Built-in presets", + "customLayout": "Custom layout {{number}}", + "customPresets": "Custom presets", + "deleteLayoutPreset": "Delete preset", + "deleteLayoutPresetBody": "Delete \"{{name}}\"? This only removes the saved preset.", + "deleteLayoutPresetQuestion": "Delete layout preset?", + "layoutPresetName": "Preset name", + "renameLayoutPreset": "Rename layout preset" + }, + "widgets": { + "actionsLabel": "{{label}} actions", + "removeWidget": "Remove {{label}}", + "resizePanel": "Resize {{region}} widget panel", + "settingsLabel": "{{label}} settings", + "visibilityLabel": "{{region}} widget visibility", + "groupLabel": "Widgets", + "labels": { + "autosaveStatus": "Autosave", + "canvas": "Canvas", + "diagnostics": "Diagnostics", + "gallery": "Gallery", + "generate": "Generate", + "layers": "Layers", + "notifications": "Notifications", + "preview": "Preview", + "project": "Project", + "queue": "Queue", + "serverStatus": "Server Status", + "versionStatus": "Version", + "workflow": "Workflow" + }, + "generate": { + "addPromptTrigger": "Add prompt trigger", + "activeCount_one": "{{count}} active", + "activeCount_other": "{{count}} active", + "advanced": "Advanced", + "addConcept": "Add concept", + "addConceptsHelp": "Add LoRAs/concepts here to blend them into this generation.", + "aspectRatio": "Aspect ratio", + "cfgRescale": "CFG rescale", + "clipSkip": "CLIP skip", + "colorCompensation": "Color compensation", + "components": "Components", + "compositing": "Compositing", + "concepts": "Concepts", + "conceptWeight": "{{name}} weight", + "compatibleEmbeddings": "Compatible embeddings", + "couldNotExpandPrompt": "Could not expand the prompt.", + "couldNotGeneratePromptFromImage": "Could not generate a prompt from the selected image.", + "deletePromptHistoryItem": "Delete prompt history item", + "denoisingStrength": "Denoising strength", + "denoisingStrengthHelp": "How much the generation reworks the existing canvas content. Only applies to image-to-image (when content fills the generation frame).", + "dimensions": "Dimensions", + "disableConcept": "Disable {{name}}", + "enableNegativePrompt": "Enable negative prompt", + "enableConcept": "Enable {{name}}", + "expand": "Expand", + "expandPrompt": "Expand prompt", + "generatePrompt": "Generate Prompt", + "imageToPrompt": "Image to prompt", + "height": "Height", + "incompatible": "Incompatible", + "incompatibleSettingsCleared": "Incompatible settings cleared", + "incompatibleSettingsClearedDescription_one": "{{labels}} was not compatible with {{name}}.", + "incompatibleSettingsClearedDescription_other": "{{labels}} were not compatible with {{name}}.", + "installTextLlmToExpandPrompts": "Install a Text LLM model to expand prompts.", + "installVisionModelToGeneratePrompts": "Install a LLaVA OneVision model to generate prompts from images.", + "lockAspectRatio": "Lock aspect ratio", + "mainModel": "Main model", + "model": "Model", + "modelDefault": "Model default", + "multipleOf": "Multiple of {{value}}", + "negativePrompt": "Negative prompt", + "noMatchingPrompts": "No matching prompts.", + "noMatchingTriggers": "No matching triggers.", + "noPromptHistoryYet": "No prompt history yet.", + "noPromptTriggersAvailable": "No prompt triggers available.", + "noTextLlmInstalled": "No Text LLM installed", + "noVisionModelInstalled": "No vision model installed", + "offCount_one": "{{count}} off", + "offCount_other": "{{count}} off", + "positivePrompt": "Positive prompt", + "promptHistory": "Prompt history", + "promptHistoryEntries": "Prompt history entries", + "promptHistoryKeyboardHelp": "Alt+Up/Down switches between prompts while focused.", + "promptTemplate": "Prompt Template", + "promptTriggerOptions": "Prompt trigger options", + "random": "Random", + "activeSteps": "Active steps", + "clearReferenceImages": "Remove all", + "collapseReferenceImage": "Collapse reference image", + "cropReferenceImage": "Crop reference image", + "cropReferenceImageHelp": "Drag the crop box to move it, or drag its handles to resize. Apply to create a cropped reference image.", + "disableReferenceImage": "Disable reference image", + "enableReferenceImage": "Enable reference image", + "expandReferenceImage": "Expand reference image", + "imageInfluence": "Image influence", + "mode": "Mode", + "modeBoth": "Both", + "modeComposition": "Composition", + "modeStyle": "Style", + "pullBboxIntoReferenceImage": "Pull Bbox into Reference Image", + "referenceImage": "Reference Image", + "referenceImageModel": "Reference image model", + "referenceImages": "Reference images", + "referenceImagesHelp": "Upload an image or drag images from Gallery here.", + "referenceImagesUnsupported": "Reference images are not supported by the selected model.", + "removeReferenceImage": "Remove reference image", + "styleVariant": "Style variant", + "styleVariantPrecise": "Precise", + "styleVariantPreciseDesc": "Applies a precise visual style, eliminating subject influence.", + "styleVariantStandard": "Standard", + "styleVariantStandardDesc": "Applies visual style (colors, textures) without considering its layout. Previously called Style Only.", + "styleVariantStrong": "Strong", + "styleVariantStrongDesc": "Applies a strong visual style, with a slightly reduced composition influence.", + "useSize": "Use size", + "removeConcept": "Remove concept", + "removeConceptNamed": "Remove {{name}}", + "resizeNegativePrompt": "Resize negative prompt", + "resizePositivePrompt": "Resize positive prompt", + "scheduler": "Scheduler", + "searchPromptHistory": "Search prompt history", + "searchPromptTriggers": "Search prompt triggers", + "searchCompatibleConcepts": "Search compatible concepts...", + "seamlessTiling": "Seamless tiling", + "selectImageFirst": "Select an image in Gallery or Preview first.", + "selectMainModelBeforeConcepts": "Select a main model before adding concepts.", + "selectModel": "Select a model…", + "selectModelFirst": "Select a model first", + "selectTextLlm": "Select Text LLM", + "selectVisionModel": "Select vision model", + "setOptimalSize": "Set optimal size", + "setOptimalSizeDescription": "Set size to the model's optimal pixel count", + "shuffleSeed": "Shuffle seed", + "showDynamicPrompts": "Show dynamic prompts", + "steps": "Steps", + "swapWidthAndHeight": "Swap width and height", + "tileX": "Tile X", + "tileY": "Tile Y", + "unlockAspectRatio": "Unlock aspect ratio", + "useModelDefault": "Use model default", + "useConceptDefaultWeight": "Use concept default weight", + "useModelDefaultCfgRescale": "Use model default CFG rescale", + "useModelDefaultField": "Use model default {{field}}", + "useModelDefaultHeight": "Use model default height", + "useModelDefaultScheduler": "Use model default scheduler", + "useModelDefaultSteps": "Use model default steps", + "useModelDefaultVae": "Use model default VAE", + "useModelDefaultVaePrecision": "Use model default VAE precision", + "useModelDefaultWidth": "Use model default width", + "usePrompt": "Use prompt", + "usingBundledVae": "Using the VAE bundled with the model.", + "upload": "Upload", + "vae": "VAE", + "vaePrecision": "VAE precision", + "weight": "Weight", + "width": "Width", + "xAxis": "X axis", + "yAxis": "Y axis", + "compositingOptions": { + "infillMethod": "Infill method", + "infillMethods": { + "patchmatch": "PatchMatch", + "lama": "LaMa", + "cv2": "OpenCV", + "color": "Solid color", + "tile": "Tile" + }, + "coherenceMode": "Coherence pass mode", + "coherenceModes": { + "Gaussian Blur": "Gaussian Blur", + "Box Blur": "Box Blur", + "Staged": "Staged" + }, + "coherenceEdgeSize": "Coherence edge size", + "coherenceMinDenoise": "Coherence min denoise", + "maskBlur": "Mask blur" + } + }, + "graph": { + "setSource": "Set Source", + "viewGraph": "View Graph" + }, + "project": { + "copyId": "Copy project id", + "deleteRecovery": "Delete recovery", + "deleteRecoveryAria": "Delete {{name}}", + "deleteRecoveryBody": "Delete \"{{name}}\"? The recovery copy is removed permanently.", + "deleteRecoveryTitle": "Delete recovery?", + "events": "Events", + "graphNodes": "Graph nodes", + "idCopied": "Project id copied", + "nameHelp": "Saved with the project.", + "nameLabel": "Project name", + "openOriginal": "Open original \"{{name}}\"", + "openRecovery": "Open {{name}}", + "queueItems": "Queue items", + "recoveries": "Recoveries", + "recoveryCopy": "Recovery copy", + "recoveryForkedDescription": "Forked {{time}} after this project was changed elsewhere.", + "sync": "Sync", + "syncOffline": "Offline — saved in this browser", + "syncSynced": "Synced (revision {{revision}})", + "syncWaiting": "Waiting to sync" + }, + "preview": { + "commands": { + "deletePreviewImage": "Delete preview image", + "nextComparisonMode": "Next comparison mode", + "swapComparisonImages": "Swap comparison images", + "togglePreview": "Toggle preview" + }, + "compare": "Compare", + "emptyDescription": "Generate to Gallery, then select a thumbnail in the Gallery widget to inspect it here.", + "exitCompare": "Exit Compare", + "hideInProgressDiffusion": "Hide in-progress diffusion", + "imageCount_one": "{{count}} image", + "imageCount_other": "{{count}} images", + "loadingBoard": "Loading board", + "nextImageInBoard": "Next image in board", + "noGallerySelection": "No gallery selection", + "previousImageInBoard": "Previous image in board", + "showInProgressDiffusion": "Show in-progress diffusion", + "sideBySide": "Side by Side", + "slider": "Slider", + "sourceRun": "Source run {{id}}", + "viewing": "Viewing" + }, + "queue": { + "allScopedItemsRequested": "All scoped queue items were requested.", + "actionConfirmationTitle": "{{action}}?", + "backendItem": "Backend queue item {{id}}.", + "batch": "Batch", + "cancelAllExceptCurrent": "Cancel all except current item", + "cancelAllExceptCurrentRequested": "All pending scoped items except the current item were requested.", + "cancelAllItems": "Cancel All Items", + "cancelConfirmationBody": "This requests cancellation for queue work in the current scope. In-progress items may stop after the backend acknowledges cancellation.", + "cancelCurrent": "Cancel Current", + "cancelCurrentItem": "Cancel Current Item", + "cancelItem": "Cancel queue item #{{id}}", + "cancellationRequested": "Cancellation requested", + "clearAllItems": "Clear All Items", + "clearFailedConfirmationBody": "This permanently removes failed queue items in the current queue scope.", + "clearFailedItems": "Clear Failed Items", + "clearFailedTitle": "Clear failed queue items?", + "clearQueue": "Clear Queue", + "clearQueueConfirmationBody": "This permanently clears queue items in the current queue scope.", + "clearQueueTitle": "Clear queue?", + "couldNotCancelCurrentItem": "Could not cancel the current item.", + "couldNotCancelItem": "Could not cancel this item.", + "couldNotCancelItems": "Could not cancel scoped queue items.", + "couldNotChangeProcessor": "Could not change the queue processor.", + "couldNotClearFailedItems": "Could not clear failed queue items.", + "couldNotClearQueue": "Could not clear the queue.", + "currentBatch": "Current Batch", + "failedItemsCleared": "Failed queue items cleared", + "failedToCancelCurrentItem": "Failed to cancel current item", + "failedToCancelItems": "Failed to cancel queue items", + "failedToClearItems": "Failed to clear queue items", + "failedToClearQueue": "Failed to clear queue", + "failedToPauseProcessor": "Failed to pause processor", + "failedToResumeProcessor": "Failed to resume processor", + "filterRecentByStatus": "Filter recent queue items by status", + "generating_one": "{{count}} generating", + "generating_other": "{{count}} generating", + "itemJsonLabel": "Queue item {{id}} JSON", + "itemProgress": "Queue item progress", + "itemTitle": "Queue item #{{id}}", + "loadSettingsIntoGenerate": "Load these settings into Generate", + "loading": "Loading queue…", + "modelCache": { + "clear": "Clear cache", + "clearFailed": "Clear cache failed", + "cleared": "Model cache cleared", + "clearedDescription_one": "Cleared {{count}} cached model and freed {{bytes}}.", + "clearedDescription_other": "Cleared {{count}} cached models and freed {{bytes}}.", + "couldNotClear": "Could not clear the model cache.", + "hits": "Hits", + "label": "Model Cache", + "loaded": "Loaded", + "misses": "Misses", + "noModelsCleared": "No cached models cleared", + "noModelsClearedDescription": "No unlocked cached models were available to clear. Active models stay loaded until they are no longer in use.", + "usage": "Model cache usage", + "usedOfTotal": "{{used}} of {{total}} used" + }, + "noCurrentItem": "No current item", + "noCurrentItemDescription": "The scoped queue has no in-progress item to cancel.", + "noPrompt": "No prompt", + "openQueue": "Open Queue", + "pauseProcessor": "Pause Processor", + "processorPaused": "Processor paused", + "processorResumed": "Processor resumed", + "queueCancellationRequested": "Queue cancellation requested", + "queueCleared": "Queue cleared", + "queued_one": "{{count}} queued", + "queued_other": "{{count}} queued", + "recallAll": "Recall All", + "recallFromGallery": "Recall this generation from the gallery", + "resumeProcessor": "Resume Processor", + "sendToCanvas": "Send to canvas", + "sendToCanvasComingSoon": "Sending queue results to the canvas is coming soon.", + "settingsRecalled": "Settings recalled", + "settingsRecalledDescription": "Loaded these settings into Generate.", + "took": "Took", + "waiting_one": "{{count}} waiting", + "waiting_other": "{{count}} waiting" + }, + "autosaveStatus": { + "chipLabel": "Autosave: {{status}}", + "label": "Autosave", + "lastSaved": "Last saved: {{time}}", + "status": "Status: {{status}}" + }, + "canvas": { + "acceptToLayer": "Accept to Layer", + "candidateCount": "{{current}} of {{total}}", + "commands": { + "decreaseBrushSize": "Decrease brush/eraser size", + "deleteLayer": "Delete layer", + "deleteSelected": "Delete selected canvas item", + "deselect": "Deselect", + "duplicateLayer": "Duplicate layer", + "fitBbox": "Fit generation frame", + "increaseBrushSize": "Increase brush/eraser size", + "invertSelection": "Invert selection", + "layerBackward": "Send layer backward", + "layerForward": "Bring layer forward", + "layerToBack": "Send layer to back", + "layerToFront": "Bring layer to front", + "mergeDown": "Merge layer down", + "nextEntity": "Next canvas entity", + "nudgeDown": "Nudge layer down", + "nudgeDownLarge": "Nudge layer down 10px", + "nudgeLeft": "Nudge layer left", + "nudgeLeftLarge": "Nudge layer left 10px", + "nudgeRight": "Nudge layer right", + "nudgeRightLarge": "Nudge layer right 10px", + "nudgeUp": "Nudge layer up", + "nudgeUpLarge": "Nudge layer up 10px", + "previousEntity": "Previous canvas entity", + "redo": "Redo canvas edit", + "reorderLayer": "Reorder layer", + "resetSelected": "Clear selected mask", + "selectAll": "Select all", + "selectBboxTool": "Select generation frame tool", + "selectBrushTool": "Select brush tool", + "selectEraserTool": "Select eraser tool", + "selectGradientTool": "Select gradient tool", + "selectLassoTool": "Select lasso tool", + "selectMoveTool": "Select move tool", + "selectShapeTool": "Select shape tool", + "selectTextTool": "Select text tool", + "selectTransformTool": "Select transform tool", + "selectViewTool": "Select view tool", + "undo": "Undo canvas edit" + }, + "contextMenu": { + "empty": "This region has no visible raster content.", + "notReady": "The canvas is still preparing image data.", + "saveBboxToGallery": "Save BBox to Gallery", + "saveCanvasToGallery": "Save Canvas to Gallery", + "saveError": "Couldn't save to gallery", + "saveToGallery": "Save to Gallery", + "saved": "Saved to gallery", + "savedDescription": "{{name}} was added to your gallery.", + "stale": "The canvas changed while saving. Try again." + }, + "controls": { + "fitBboxToLayers": "Fit frame to layers", + "fitBboxToMasks": "Fit frame to masks", + "fitToView": "Fit to view", + "frameSize": "Generation frame size", + "newCanvas": "New canvas", + "newCanvasConfirm": "Clear all layers and start a fresh canvas? This cannot be undone.", + "newSession": "New session", + "zoomLevel": "Zoom level" + }, + "hideStagedResultPreview": "Hide staged result preview", + "hideStagingThumbnails": "Hide staging thumbnails", + "import": { + "blocked": "Finish the current canvas operation before adding images.", + "control": "Control Layer", + "dropControl": "Add as Control Layer", + "dropInpaintMask": "Add as Inpaint Mask", + "dropRaster": "Add as Raster Layer", + "dropRegionalReference": "Add as Regional Reference Image", + "dropResizedControl": "Add as Resized Control Layer", + "empty": "No gallery images were available to add.", + "failed": "Couldn't add images to canvas", + "inpaintMask": "Inpaint Mask", + "newLayerFromImage": "New Layer from Image", + "partial_one": "Added {{successCount}} image; {{failedCount}} could not be prepared.", + "partial_other": "Added {{successCount}} images; {{failedCount}} could not be prepared.", + "raster": "Raster Layer", + "regionalGuidance": "Regional Guidance", + "regionalReference": "Regional Reference Image", + "resizedControl": "Resized Control Layer", + "staleDocument": "The canvas changed while the images were being prepared. Try again.", + "staleProject": "The destination project was closed before the images were ready.", + "success_one": "Added {{count}} image as canvas layers", + "success_other": "Added {{count}} images as canvas layers" + }, + "nextStagedCandidate": "Next staged candidate", + "previousStagedCandidate": "Previous staged candidate", + "selectStagedCandidate": "Select staged candidate {{number}}", + "settings": { + "bboxOverlay": "Show bbox overlay", + "checkerboard": "Checkerboard background", + "clearCaches": "Clear caches", + "clearHistory": "Clear history", + "grid": "Show grid", + "invertBrushScroll": "Invert scroll for brush size", + "label": "Canvas settings", + "logDebugInfo": "Log debug info", + "ruleOfThirds": "Rule of thirds", + "sections": { + "behavior": "Behavior", + "debug": "Debug", + "display": "Display", + "grid": "Grid" + }, + "showBbox": "Show generation frame", + "showProgressOnCanvas": "Show progress on canvas", + "snapToGrid": "Snap to grid" + }, + "showStagedResultPreview": "Show staged result preview", + "showStagingThumbnails": "Show staging thumbnails", + "staging": { + "autoSwitch": "Auto-switch", + "autoSwitchLatest": "Newest", + "autoSwitchOff": "Off", + "autoSwitchProgress": "In progress", + "generating": "Generating…", + "saveError": "Couldn't save to gallery", + "saveToGallery": "Save to gallery", + "saved": "Saved to gallery", + "savedDescription": "{{name}} added to your gallery." + }, + "surface": "Canvas surface", + "toolOptions": { + "applyTransform": "Apply", + "aspectRatio": "Aspect ratio", + "brushColor": "Brush color", + "brushSize": "Brush size", + "cancelTransform": "Cancel", + "deselect": "Deselect", + "eraseSelection": "Erase", + "eraserSize": "Eraser size", + "fillSelection": "Fill", + "frameHeight": "H", + "frameWidth": "W", + "gradientAngle": "Angle", + "gradientEdit": "Edit gradient", + "gradientEnd": "End", + "gradientEndOpacity": "End opacity", + "gradientKind": "Gradient type", + "gradientLinear": "Linear", + "gradientRadial": "Radial", + "gradientStart": "Start", + "gradientStartOpacity": "Start opacity", + "invertSelection": "Invert", + "lassoHint": "Draw a shape to select. Shift adds, Alt subtracts.", + "lockAspect": "Lock aspect ratio", + "movePosition": "Move layer", + "opacity": "Opacity", + "positionX": "X", + "positionY": "Y", + "pressureSensitivity": "Pressure sensitivity", + "rotation": "Rotation", + "scaleHeight": "H %", + "scaleWidth": "W %", + "selectionAdd": "Add", + "selectionIntersect": "Intersect", + "selectionMode": "Selection mode", + "selectionReplace": "Replace", + "selectionSubtract": "Subtract", + "setFrame": "Set generation frame", + "shapeEdit": "Edit shape", + "shapeEllipse": "Ellipse", + "shapeFill": "Fill", + "shapeHint": "Drag to draw a shape.", + "shapeKind": "Shape type", + "shapeRect": "Rectangle", + "shapeStroke": "Stroke", + "shapeStrokeWidth": "Stroke width", + "textAlignCenter": "Align center", + "textAlignLeft": "Align left", + "textAlignRight": "Align right", + "textColor": "Text color", + "textEdit": "Edit text", + "textFont": "Font", + "textLineHeight": "Line height", + "textSize": "Font size", + "textWeight": "Weight" + }, + "tools": { + "bbox": "Generation frame", + "brush": "Brush", + "eraser": "Eraser", + "gradient": "Gradient", + "label": "Tools", + "lasso": "Lasso select", + "move": "Move", + "shape": "Shape", + "text": "Text", + "transform": "Transform", + "view": "View" + } + }, + "workflow": { + "about": "About", + "addNode": "Add node", + "author": "Author", + "class": "Class", + "connectorNode": "Connector node", + "contact": "Contact", + "copyJson": "Copy workflow JSON", + "copyJsonFailed": "Failed to copy workflow JSON", + "copyJsonSuccess": "Workflow JSON copied", + "currentImageNode": "Current Image node", + "declaredOutputs": "Declared outputs", + "description": "Description", + "details": "Details", + "detailsWithEllipsis": "Workflow details…", + "exportJson": "Export workflow JSON", + "form": "Form", + "graphHistorySnapshots": "Graph History Snapshots", + "importJsonWithEllipsis": "Import workflow JSON…", + "inspectorTabs": { + "data": "Data", + "details": "Details", + "outputs": "Outputs", + "template": "Template" + }, + "library": "Workflow library", + "name": "Workflow name", + "nodeData": "Node data", + "nodeInspector": "Node Inspector", + "nodeNotes": "Node notes", + "nodeNotesPlaceholder": "Notes about this node…", + "nodeTemplate": "Node template", + "notes": "Notes", + "notesNode": "Notes node", + "noTemplateKnown": "No template is known for \"{{type}}\" on this backend.", + "pack": "Pack", + "panelContent": "Workflow panel content", + "resizeNodeInspector": "Resize node inspector", + "runOutputsNotRecorded": "Run outputs are not recorded per node yet; results currently route to the Gallery or Canvas. This tab will show the node's outputs from its most recent run once run records land.", + "newWorkflowWithEllipsis": "New workflow…", + "saveGraphSnapshot": "Save graph snapshot", + "selectNodeToInspect": "Select a node in the Workflow editor to inspect it.", + "selectedNodeInspector": "Selected node inspector", + "tags": "Tags", + "title": "Title", + "type": "Type", + "version": "Version", + "workflowJson": "Workflow JSON", + "untitled": "Untitled Workflow" + }, + "layers": { + "actions": { + "addControlLayer": "Control layer", + "addDenoiseLimit": "Add denoise limit", + "addImageNoise": "Add image noise", + "addInpaintMask": "Inpaint mask", + "addNegativePrompt": "Add negative prompt", + "addPositivePrompt": "Add positive prompt", + "addRasterLayer": "Raster layer", + "addReferenceImage": "Add reference image", + "addRegionalGuidance": "Regional guidance", + "addRegionalReferenceImage": "Regional reference image", + "actionFailed": "Layer action failed", + "adjustments": "Adjustments", + "blendMode": "Blend mode", + "busy": "The canvas is busy. Try again when the current action finishes.", + "copyFailed": "The layer could not be copied.", + "copyLayerToClipboard": "Copy layer to clipboard", + "copyToControl": "Copy to control layer", + "copyToInpaintMask": "Copy to inpaint mask", + "copyToRasterLayer": "Copy to raster layer", + "copyToRegionalGuidance": "Copy to regional guidance", + "convertToControl": "Convert to control layer", + "convertToInpaintMask": "Convert to inpaint mask", + "convertToRaster": "Convert to raster layer", + "convertToRegionalGuidance": "Convert to regional guidance", + "cropBusy": "Finish the current canvas action before cropping this layer.", + "cropFailed": "The layer could not be cropped.", + "cropLayerToBbox": "Crop layer to bbox", + "cropUnsupported": "This layer cannot be cropped.", + "cropped": "Layer cropped to bbox.", + "cutaway": "Cutaway with layer below", + "cutout": "Cutout with layer below", + "delete": "Delete layer", + "disableAutoNegative": "Disable auto-negative", + "disableTransparencyEffect": "Disable transparency effect", + "disabled": "Enable the layer before using this action.", + "duplicate": "Duplicate layer", + "empty": "This layer has no content.", + "enableAutoNegative": "Enable auto-negative", + "enableTransparencyEffect": "Enable transparency effect", + "exclude": "Exclude layer below", + "extractMaskedArea": "Extract masked area", + "fitToBbox": "Fit to bbox", + "hide": "Hide layer", + "intersect": "Intersect with layer below", + "lock": "Lock layer", + "locked": "Unlock the layer before using this action.", + "mergeDown": "Merge down", + "missing": "The layer no longer exists.", + "moveBackward": "Move backward", + "moveForward": "Move forward", + "moveToBack": "Move to back", + "moveToFront": "Move to front", + "notReady": "The layer content is still loading. Try again shortly.", + "opacity": "Opacity", + "rasterize": "Rasterize layer", + "rename": "Rename layer", + "reorder": "Reorder layer", + "runWorkflow": "Run workflow", + "saveLayerToAssets": "Save layer to assets", + "selectObject": "Select object", + "show": "Show layer", + "toggleAutoNegative": "Toggle auto-negative", + "toggleLock": "Toggle lock", + "toggleTransparencyEffect": "Toggle transparency effect", + "toggleVisibility": "Toggle visibility", + "transform": "Transform", + "unsupported": "This action is not supported for this layer.", + "unlock": "Unlock layer" + }, + "control": { + "apply": "Apply", + "applyFilter": "Apply filter", + "beginStep": "Begin step", + "cancel": "Cancel", + "endStep": "End step", + "filter": "Filter", + "filterParams": { + "amount": "Amount", + "autoScale": "Autoscale", + "blur_type": "Blur type", + "channel": "Channel", + "coarse": "Coarse", + "distance_threshold": "Distance threshold", + "draw_body": "Draw body", + "draw_face": "Draw face", + "draw_hands": "Draw hands", + "high_threshold": "High threshold", + "low_threshold": "Low threshold", + "max_faces": "Max faces", + "min_confidence": "Min confidence", + "model": "Image-to-image model", + "model_size": "Model size", + "quantize_edges": "Quantize edges", + "scale_factor": "Scale factor", + "scale": "Scale", + "scale_values": "Multiply values", + "score_threshold": "Score threshold", + "scribble": "Scribble", + "tile_size": "Tile size", + "noise_color": "Color noise", + "noise_type": "Noise type", + "radius": "Radius", + "size": "Size", + "value": "Value" + }, + "filterOptions": { + "blur_type": { + "box": "Box", + "gaussian": "Gaussian" + }, + "channel": { + "alpha": "Alpha (RGBA)", + "black": "Black (CMYK)", + "blue": "Blue (RGBA)", + "cb": "Cb (YCbCr)", + "cr": "Cr (YCbCr)", + "cyan": "Cyan (CMYK)", + "green": "Green (RGBA)", + "hue": "Hue (HSV)", + "lab_a": "A (LAB)", + "lab_b": "B (LAB)", + "luminosity": "Luminosity (LAB)", + "magenta": "Magenta (CMYK)", + "red": "Red (RGBA)", + "saturation": "Saturation (HSV)", + "value": "Value (HSV)", + "y": "Y (YCbCr)", + "yellow": "Yellow (CMYK)" + }, + "model_size": { + "base": "Base", + "large": "Large", + "small": "Small", + "small_v2": "Small v2" + }, + "noise_type": { + "gaussian": "Gaussian", + "salt_and_pepper": "Salt and pepper" + } + }, + "filters": { + "adjust_image": "Adjust image", + "canny_edge_detection": "Canny", + "color_map": "Color map", + "content_shuffle": "Content shuffle", + "depth_anything_depth_estimation": "Depth Anything", + "dw_openpose_detection": "DW Openpose", + "hed_edge_detection": "HED", + "img_blur": "Blur", + "img_noise": "Noise", + "lineart_anime_edge_detection": "Lineart Anime", + "lineart_edge_detection": "Lineart", + "mediapipe_face_detection": "Mediapipe Face", + "mlsd_detection": "MLSD", + "normal_map": "Normal map", + "pidi_edge_detection": "PiDiNet", + "spandrel_filter": "Spandrel" + }, + "kind": "Adapter type", + "kinds": { + "control_lora": "Control LoRA", + "controlnet": "ControlNet", + "t2i_adapter": "T2I Adapter", + "z_image_control": "Z-Image ControlNet" + }, + "mode": "Control mode", + "model": "Model", + "modes": { + "balanced": "Balanced", + "more_control": "More control", + "more_prompt": "More prompt", + "unbalanced": "Unbalanced" + }, + "preview": "Preview filter", + "selectModel": "Select a model", + "stepRange": "Step range", + "transparencyEffect": "Transparency effect", + "validation": { + "control_lora_limit": "Only one Control LoRA can be used at a time.", + "flux_fill_control_lora": "Control LoRA is not compatible with FLUX Fill.", + "incompatible_adapter": "The selected model does not match this adapter type.", + "incompatible_base": "The control model is incompatible with the selected base model.", + "invalid_adapter_values": "Control weight and step range must be valid before generating.", + "missing_model": "Select a control model before generating.", + "unsupported_adapter": "This control adapter is not supported by the selected base model.", + "z_image_control_limit": "Only one Z-Image control can be used at a time." + }, + "weight": "Weight" + }, + "rasterFilter": { + "autoProcess": "Auto", + "autoProcessDescription": "Process automatically when settings change", + "busy": "Finish the current canvas interaction, then try again.", + "cancel": "Cancel", + "commitFailure": "The filtered pixels could not be committed: {{message}}", + "copy": "Copy", + "durabilityFailure": "The filtered image could not be preserved: {{message}}", + "graphFailure": "The filter failed: {{message}}", + "locked": "Unlock the layer before applying the filter.", + "preview": "Preview", + "replace": "Replace", + "running": "Running filter…", + "statusCommitting": "Applying filter…", + "statusError": "Filter needs attention.", + "stale": "The layer changed while filtering. Preview it again.", + "title": "Filter", + "unsupported": "This layer content cannot be filtered." + }, + "selectObject": { + "autoProcess": "Auto Process", + "bbox": "BBox", + "bboxActive": "Bounding box active", + "bboxInactive": "No bounding box", + "errorDecode": "The object preview could not be decoded.", + "errorEmpty": "The layer has no visible content to select.", + "errorInvalid": "Add an object prompt, point, or bounding box before processing.", + "errorLocked": "Select Object cannot finish while canvas editing is locked.", + "errorNoOutput": "Select Object finished without producing an image.", + "errorNotReady": "The layer source is not ready. Try again when it finishes loading.", + "errorOutputDimension": "The Select Object result dimensions do not match the layer source.", + "errorQueue": "Select Object processing failed on the backend.", + "errorReconcile": "The completed Select Object result could not be retrieved.", + "errorUnknown": "Select Object could not finish.", + "errorUpload": "The layer source could not be uploaded for Select Object.", + "exclude": "Exclude", + "excludeCount": "Exclude {{count}}", + "include": "Include", + "includeCount": "Include {{count}}", + "invert": "Invert", + "isolatedPreview": "Isolated Preview", + "model": "Model", + "modelHuge": "SAM Huge", + "modelSam2Large": "SAM 2 Large", + "mode": "Selection mode", + "prompt": "Object prompt", + "promptGuidance": "Describe the object you want to select.", + "promptMode": "Prompt", + "pointType": "Point type", + "process": "Process", + "refine": "Refine", + "reset": "Reset", + "saveAs": "Save As", + "saveAs_control": "Control layer", + "saveAs_inpaint_mask": "Inpaint mask", + "saveAs_raster": "Raster layer", + "saveAs_regional_guidance": "Regional guidance", + "saveAs_selection": "Selection", + "settings": "Select Object settings", + "sourceLayerLabel": "{{name}} · {{type}} · {{width}} × {{height}}", + "statusCommitting": "Applying object result…", + "statusError": "Select Object needs attention.", + "statusPreparingSource": "Preparing layer source…", + "statusProcessingSam": "Finding object…", + "statusReady": "Ready", + "statusRenderingPreview": "Rendering object preview…", + "statusScheduled": "Waiting to process object…", + "statusUploading": "Uploading layer source…", + "technicalDetails": "Technical details", + "title": "Select Object", + "visual": "Visual", + "visualGuidance": "Click to add · drag to move · Shift flips type" + }, + "runWorkflow": { + "aborted": "Workflow run was canceled.", + "busy": "Finish the current canvas interaction, then try again.", + "cancel": "Cancel", + "copyRaster": "Copy as raster layer", + "copySuccess": "Added the workflow result as a raster layer.", + "destination": "Destination", + "disabled": "Enable the layer before running the workflow.", + "durabilityFailure": "The workflow output could not be preserved: {{message}}", + "empty": "This layer has no content to run through the workflow.", + "failed": "Workflow run failed: {{message}}", + "gallery": "Gallery", + "gallerySuccess": "Saved the workflow result to Gallery.", + "graphFailure": "The active workflow failed: {{message}}", + "hydrationFailure": "The workflow output could not be loaded: {{message}}", + "input": "Workflow image input", + "locked": "Unlock the layer before running the workflow.", + "missing": "The source layer no longer exists.", + "noBindings": "The active workflow has no compatible image input and output.", + "notReady": "The active workflow is not ready with this layer input.", + "output": "Workflow image output", + "replace": "Replace layer", + "replaceSuccess": "Replaced the layer with the workflow result.", + "run": "Run", + "running": "Running workflow…", + "staging": "Canvas staging", + "stagingSuccess": "Added the workflow result to canvas staging.", + "stale": "The layer or project changed while the workflow was running. Run it again.", + "title": "Run workflow from layer", + "unsupported": "This layer cannot be used as a workflow image input." + }, + "addLayer": "Add layer", + "adjustments": { + "brightness": "Brightness", + "channel": "Channel", + "channels": { + "b": "Blue", + "g": "Green", + "r": "Red" + }, + "contrast": "Contrast", + "curves": "Curves", + "curvesHint": "Drag points to bend the curve. Double-click empty space to add a point; double-click a point to remove it.", + "reset": "Reset adjustments", + "saturation": "Saturation", + "title": "Adjustments", + "transparencyLock": "Lock transparency" + }, + "blendModes": { + "color": "Color", + "color-burn": "Color burn", + "color-dodge": "Color dodge", + "darken": "Darken", + "difference": "Difference", + "exclusion": "Exclusion", + "hard-light": "Hard light", + "hue": "Hue", + "lighten": "Lighten", + "luminosity": "Luminosity", + "multiply": "Multiply", + "normal": "Normal", + "overlay": "Overlay", + "saturation": "Saturation", + "screen": "Screen", + "soft-light": "Soft light" + }, + "denoisingStrength": "Denoising strength", + "denoisingStrengthHelp": "How much the canvas image is changed. Lower keeps more of the original; higher allows bigger changes.", + "empty": "No layers yet. Add a layer or paint on the canvas to get started.", + "groupActions": { + "collapse": "Collapse group", + "expand": "Expand group", + "exportNothing": "No raster layer content to export", + "exportNotReady": "Layers are still loading — try exporting again in a moment", + "exportOverBudget": "Not enough raster memory is available to export this PSD", + "exportStale": "The canvas changed during export — try again", + "exportAborted": "PSD export was canceled", + "exportFailed": "PSD export failed", + "exportPsd": "Export to PSD", + "exportTooLarge": "Canvas is too large to export to PSD", + "hideAll": "Hide all", + "mergeNotReady": "Layers are still loading — try merging again in a moment", + "mergeVisible": "Merge visible", + "new": "New layer", + "showAll": "Show all", + "toggleVisibility": "Toggle group visibility" + }, + "groups": { + "control": "Control Layers", + "inpaint_mask": "Inpaint Masks", + "raster": "Raster Layers", + "regional_guidance": "Regional Guidance" + }, + "maskFill": { + "color": "Fill color", + "denoiseLimit": "Denoise limit", + "fill": "Fill", + "invert": "Invert mask", + "noiseLevel": "Noise level", + "style": "Fill style", + "styles": { + "crosshatch": "Crosshatch", + "diagonal": "Diagonal", + "grid": "Grid", + "horizontal": "Horizontal", + "solid": "Solid", + "vertical": "Vertical" + } + }, + "menuGroups": { + "layers": "Layers", + "regional": "Regional" + }, + "menu": { + "add": "Add", + "addModifiers": "Add modifiers", + "arrange": "Arrange", + "booleanOperations": "Boolean operations", + "convertTo": "Convert to", + "copyTo": "Copy to" + }, + "options": "Layer options", + "properties": "Layer properties", + "regionalGuidance": { + "addReferenceImage": "Add reference image", + "autoNegative": "Auto-Negative", + "clearReferenceImage": "Clear reference image", + "method": "Method", + "methods": { + "composition": "Composition", + "full": "Full", + "style": "Style", + "style_precise": "Style (precise)", + "style_strong": "Style (strong)" + }, + "model": "Model", + "negativePrompt": "Negative prompt", + "negativePromptPlaceholder": "Region negative prompt", + "positivePrompt": "Positive prompt", + "positivePromptPlaceholder": "Region positive prompt", + "referenceImage": "Reference image", + "referenceImageHelp": "Upload or drag an image from the gallery.", + "referenceImages": "Reference images", + "removeReferenceImage": "Remove reference image", + "selectModel": "Select a model", + "setReferenceImage": "Set reference image", + "weight": "Weight" + }, + "types": { + "control": "Control", + "image": "Image", + "inpaint_mask": "Inpaint", + "paint": "Paint", + "regional_guidance": "Region" + } + }, + "gallery": { + "alwaysShowDimensions": "Always show dimensions", + "archiveBoard": "Archive Board", + "boardActionsForBoard": "Board actions for {{name}}", + "boardGroups": { + "boards": "Boards", + "byDate": "By Date" + }, + "boardItemCounts": "{{images}} images | {{assets}} assets", + "boardName": "Board name", + "commands": { + "clearSelection": "Clear gallery selection", + "deleteSelection": "Delete gallery selection", + "navigationDown": "Gallery navigation down", + "navigationLeft": "Gallery navigation left", + "navigationRight": "Gallery navigation right", + "navigationUp": "Gallery navigation up", + "selectAllOnPage": "Select all gallery images on page", + "toggleStarImage": "Toggle gallery image star" + }, + "createBoardNamed": "Create board \"{{name}}\"", + "deleteBoard": "Delete Board", + "deleteBoardAndImages": "Delete Board and Images", + "deleteBoardDescription": "Deleted boards cannot be restored. Choose whether the board's images move to Uncategorized or are permanently deleted with it.", + "deleteBoardOnly": "Delete Board Only", + "deleteBoardQuestion": "Delete board \"{{name}}\"?", + "downloadBoard": "Download Board", + "dropImagesToUploadToBoard": "Drop images to upload to {{name}}", + "generationProgress": "Generation progress", + "generationProgressPercent": "Generation {{percentage}}%", + "hideArchivedBoards": "Hide archived boards", + "hideDateBoards": "Hide date boards", + "imageDensity": "Image density", + "imagesAriaLabel": "Gallery images", + "imageSort": "Image Sort", + "infinite": "Infinite", + "loadingBackendGallery": "Loading backend gallery...", + "loadingBoard": "Loading board...", + "newest": "Newest", + "noBoardsMatchSearch": "No boards match this search.", + "noImagesMatch": "No images match this board, tab, or search.", + "oldest": "Oldest", + "pagination": "Pagination", + "renameBoard": "Rename board", + "searchImagesAriaLabel": "Search gallery images", + "searchImagesPlaceholder": "Search images", + "searchOrCreateBoards": "Search or create boards", + "selectImageForPreview": "Select {{name}} for preview", + "selectedBoardFallback": "selected board", + "settings": "Gallery settings", + "settingsGridDensity": "Grid Density", + "settingsThumbnails": "Thumbnails", + "showArchivedBoards": "Show archived boards", + "showDateBoards": "Show date boards", + "showStarredImagesFirst": "Show starred images first", + "sortBoardsAscending": "Sort boards ascending", + "sortBoardsDescending": "Sort boards descending", + "starImage": "Star {{name}}", + "thumbnailFitAspect": "Aspect", + "thumbnailFitSquare": "Square", + "uncategorized": "Uncategorized", + "unarchiveBoard": "Unarchive Board", + "unknownBoard": "Unknown board", + "unstarImage": "Unstar {{name}}", + "uploadImagesToBoard": "Upload images to {{name}}", + "uploadsUnavailableForDateBoards": "Uploads are unavailable for date boards" + }, + "serverStatus": { + "connected": "Connected to Server", + "connecting": "Connecting to Server", + "disconnected": "Disconnected from Server", + "label": "Server Status", + "labelWithError": "{{label}}: {{error}}" + }, + "versionStatus": { + "chipLabel": "Version 7.0", + "description": "Invoke V7 shell version 7.0.", + "label": "Version" + } + } +} diff --git a/invokeai/frontend/webv2/public/locales/es.json b/invokeai/frontend/webv2/public/locales/es.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/es.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/fi.json b/invokeai/frontend/webv2/public/locales/fi.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/fi.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/fr.json b/invokeai/frontend/webv2/public/locales/fr.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/fr.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/he.json b/invokeai/frontend/webv2/public/locales/he.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/he.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/hu.json b/invokeai/frontend/webv2/public/locales/hu.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/hu.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/it.json b/invokeai/frontend/webv2/public/locales/it.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/it.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/ja.json b/invokeai/frontend/webv2/public/locales/ja.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/ja.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/ko.json b/invokeai/frontend/webv2/public/locales/ko.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/ko.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/mn.json b/invokeai/frontend/webv2/public/locales/mn.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/mn.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/nl.json b/invokeai/frontend/webv2/public/locales/nl.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/nl.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/pl.json b/invokeai/frontend/webv2/public/locales/pl.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/pl.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/pt-BR.json b/invokeai/frontend/webv2/public/locales/pt-BR.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/pt-BR.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/pt.json b/invokeai/frontend/webv2/public/locales/pt.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/pt.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/ro.json b/invokeai/frontend/webv2/public/locales/ro.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/ro.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/ru.json b/invokeai/frontend/webv2/public/locales/ru.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/ru.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/sv.json b/invokeai/frontend/webv2/public/locales/sv.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/sv.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/tr.json b/invokeai/frontend/webv2/public/locales/tr.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/tr.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/uk.json b/invokeai/frontend/webv2/public/locales/uk.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/uk.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/vi.json b/invokeai/frontend/webv2/public/locales/vi.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/vi.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/zh-CN.json b/invokeai/frontend/webv2/public/locales/zh-CN.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/zh-CN.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/zh-Hant.json b/invokeai/frontend/webv2/public/locales/zh-Hant.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/zh-Hant.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/scripts/i18n-report.mjs b/invokeai/frontend/webv2/scripts/i18n-report.mjs new file mode 100644 index 00000000000..4c16f00b7de --- /dev/null +++ b/invokeai/frontend/webv2/scripts/i18n-report.mjs @@ -0,0 +1,51 @@ +/* eslint-disable no-console */ + +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const localeDir = join(process.cwd(), 'public/locales'); +const verbose = process.argv.includes('--verbose'); + +const flattenKeys = (value, prefix = '') => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return prefix ? [prefix] : []; + } + + return Object.entries(value).flatMap(([key, child]) => flattenKeys(child, prefix ? `${prefix}.${key}` : key)); +}; + +const readJson = async (fileName) => JSON.parse(await readFile(join(localeDir, fileName), 'utf8')); + +const localeFiles = (await readdir(localeDir)).filter((fileName) => fileName.endsWith('.json')).sort(); +const english = await readJson('en.json'); +const englishKeys = flattenKeys(english); +const englishKeySet = new Set(englishKeys); +let missingCount = 0; + +for (const fileName of localeFiles) { + if (fileName === 'en.json') { + continue; + } + + const locale = await readJson(fileName); + const localeKeys = new Set(flattenKeys(locale)); + const missing = englishKeys.filter((key) => !localeKeys.has(key)); + const extra = [...localeKeys].filter((key) => !englishKeySet.has(key)); + + missingCount += missing.length; + console.log(`\n${fileName}`); + console.log(` missing: ${missing.length}`); + console.log(` extra: ${extra.length}`); + + if (verbose) { + for (const key of missing) { + console.log(` - ${key}`); + } + + for (const key of extra) { + console.log(` + ${key}`); + } + } +} + +console.log(`\nTotal missing keys: ${missingCount}`); diff --git a/invokeai/frontend/webv2/src/App.tsx b/invokeai/frontend/webv2/src/App.tsx new file mode 100644 index 00000000000..9826a01e033 --- /dev/null +++ b/invokeai/frontend/webv2/src/App.tsx @@ -0,0 +1,17 @@ +import { ChakraProvider } from '@chakra-ui/react'; +import { RouterProvider } from '@tanstack/react-router'; + +import { router } from './router'; +import { system } from './theme/system'; +import { AppToaster } from './workbench/components/ui/toaster'; +import { I18nController } from './workbench/i18n'; +import { ThemeController } from './workbench/ThemeController'; + +export const App = () => ( + + + + + + +); diff --git a/invokeai/frontend/webv2/src/WorkbenchApp.tsx b/invokeai/frontend/webv2/src/WorkbenchApp.tsx new file mode 100644 index 00000000000..6ffc11d4c6c --- /dev/null +++ b/invokeai/frontend/webv2/src/WorkbenchApp.tsx @@ -0,0 +1,41 @@ +import { useSearch } from '@tanstack/react-router'; +import { WorkbenchShell } from '@workbench/shell'; +import { useMemo } from 'react'; + +import type { WorkbenchSearch } from './workbench/projects/session'; + +import { SessionExpiryGuard } from './workbench/auth/components/SessionExpiryGuard'; +import { WorkbenchHotkeyRuntime } from './workbench/hotkeys/WorkbenchHotkeyRuntime'; +import { WidgetHosts } from './workbench/widget-frame/WidgetHosts'; +import { WorkbenchProvider } from './workbench/WorkbenchContext'; +import { WorkbenchRuntime } from './workbench/WorkbenchRuntime'; +import { WorkbenchSessionController } from './workbench/WorkbenchSessionController'; + +/** + * The authenticated editor: providers, editor-only runtimes, and the shell. + * The shared backend socket is mounted above this route; `WorkbenchRuntime` + * attaches the generation queue listeners while the editor is open. + * + * The route's search params shape the boot: ?project deep-links a library + * project into the session, ?new starts a fresh draft. Both are consumed by + * the persistence load; the session controller handles params that change + * while the editor is already mounted. + */ +export const WorkbenchApp = () => { + const search = useSearch({ strict: false }) as WorkbenchSearch; + const loadOptions = useMemo( + () => ({ createNew: search.new, openProjectId: search.project }), + [search.new, search.project] + ); + + return ( + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/appMetadata.ts b/invokeai/frontend/webv2/src/appMetadata.ts new file mode 100644 index 00000000000..c3e51ede928 --- /dev/null +++ b/invokeai/frontend/webv2/src/appMetadata.ts @@ -0,0 +1 @@ +export const APP_VERSION = '7.0'; diff --git a/invokeai/frontend/webv2/src/assets/SplashImage.webp b/invokeai/frontend/webv2/src/assets/SplashImage.webp new file mode 100644 index 00000000000..02ab32d523c Binary files /dev/null and b/invokeai/frontend/webv2/src/assets/SplashImage.webp differ diff --git a/invokeai/frontend/webv2/src/i18n.ts b/invokeai/frontend/webv2/src/i18n.ts new file mode 100644 index 00000000000..9d014a8cbb1 --- /dev/null +++ b/invokeai/frontend/webv2/src/i18n.ts @@ -0,0 +1,20 @@ +import i18n from 'i18next'; +import Backend from 'i18next-http-backend'; +import { initReactI18next } from 'react-i18next'; + +void i18n + .use(Backend) + .use(initReactI18next) + .init({ + backend: { + loadPath: `${import.meta.env.BASE_URL}locales/{{lng}}.json`, + }, + debug: false, + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, + returnNull: false, + }); + +export default i18n; diff --git a/invokeai/frontend/webv2/src/lucide-icons.d.ts b/invokeai/frontend/webv2/src/lucide-icons.d.ts new file mode 100644 index 00000000000..0330f2f1c87 --- /dev/null +++ b/invokeai/frontend/webv2/src/lucide-icons.d.ts @@ -0,0 +1,6 @@ +declare module 'lucide-react/dist/esm/icons/*.mjs' { + import type { ComponentType, SVGProps } from 'react'; + + const Icon: ComponentType>; + export default Icon; +} diff --git a/invokeai/frontend/webv2/src/main.tsx b/invokeai/frontend/webv2/src/main.tsx new file mode 100644 index 00000000000..6c81cc73d19 --- /dev/null +++ b/invokeai/frontend/webv2/src/main.tsx @@ -0,0 +1,18 @@ +import '@fontsource/inter/index.css'; +import './i18n'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { App } from './App'; + +const rootElement = document.getElementById('root'); + +if (!rootElement) { + throw new Error('Unable to mount Invoke V7: root element was not found.'); +} + +createRoot(rootElement).render( + + + +); diff --git a/invokeai/frontend/webv2/src/router.tsx b/invokeai/frontend/webv2/src/router.tsx new file mode 100644 index 00000000000..141bb865a3e --- /dev/null +++ b/invokeai/frontend/webv2/src/router.tsx @@ -0,0 +1,203 @@ +import { + createHashHistory, + createRootRoute, + createRoute, + createRouter, + lazyRouteComponent, + Navigate, + Outlet, + redirect, +} from '@tanstack/react-router'; + +import { getCapabilities, type Capabilities } from './workbench/auth/capabilities'; +import { LoginScreen } from './workbench/auth/components/LoginScreen'; +import { SetupScreen } from './workbench/auth/components/SetupScreen'; +import { ensureAuthSession } from './workbench/auth/session'; +import { SocketHubRuntime } from './workbench/backend/SocketHubRuntime'; +import { WorkbenchSplashScreen } from './workbench/components/WorkbenchSplashScreen'; +import { Launchpad } from './workbench/launchpad/Launchpad'; +import { peekOpenProjectIds, type WorkbenchSearch } from './workbench/projects/session'; +import { loadWorkbenchSettings } from './workbench/settings/store'; + +/** + * Code-based route tree. The authenticated layout route owns the auth guard, + * which resolves the backend's multi-user status once per app load: + * + * - multi-user off → authenticated routes render directly; /login + /setup bounce home + * - setup required → everything funnels to /setup + * - signed out → authenticated routes redirect to /login + * + * Under it, `/` is the Launchpad (project library, model manager, profile, + * resources) and `/app` is the editor. The editor bundle — canvas, workflow, + * widgets — is code-split behind `lazyRouteComponent`, so the Launchpad stays + * light; `defaultPreload: 'intent'` starts fetching that chunk the moment a + * project card is hovered. + * + * Hash history is used because the bundle is served with a relative base + * (`./`), so the app cannot rely on server-side fallback for deep paths. + */ + +const rootRoute = createRootRoute({ component: Outlet }); + +/** + * Wraps every authenticated route with the shared backend socket transport. + * Feature runtimes attach their own listeners only where needed, keeping the + * light Launchpad bundle free of editor/model-manager code. + */ +const AuthenticatedLayout = () => ( + <> + + + +); + +const authenticatedRoute = createRoute({ + beforeLoad: async () => { + const session = await ensureAuthSession(); + + if (session.multiuserEnabled) { + if (session.setupRequired) { + throw redirect({ to: '/setup' }); + } + + if (session.user === null) { + throw redirect({ to: '/login' }); + } + } + + await loadWorkbenchSettings(); + }, + component: AuthenticatedLayout, + getParentRoute: () => rootRoute, + id: 'authenticated', +}); + +const requireLaunchpadCapability = async (capability: keyof Capabilities): Promise => { + const session = await ensureAuthSession(); + + if (!getCapabilities(session)[capability]) { + throw redirect({ to: '/projects' }); + } +}; + +// `/` plus a deep-link alias per Launchpad section. All render the same shell; +// it reads the path to pick the active page. +const homeRoute = createRoute({ + component: Launchpad, + getParentRoute: () => authenticatedRoute, + path: '/', +}); + +const projectsHomeRoute = createRoute({ + component: Launchpad, + getParentRoute: () => authenticatedRoute, + path: 'projects', +}); + +const modelsHomeRoute = createRoute({ + beforeLoad: () => requireLaunchpadCapability('canManageModels'), + component: Launchpad, + getParentRoute: () => authenticatedRoute, + path: 'models', +}); + +const nodesHomeRoute = createRoute({ + beforeLoad: () => requireLaunchpadCapability('canManageNodes'), + component: Launchpad, + getParentRoute: () => authenticatedRoute, + path: 'nodes', +}); + +const usersHomeRoute = createRoute({ + beforeLoad: () => requireLaunchpadCapability('canManageUsers'), + component: Launchpad, + getParentRoute: () => authenticatedRoute, + path: 'users', +}); + +const workbenchRoute = createRoute({ + beforeLoad: async ({ cause, search }) => { + // Photoshop semantics: the editor without documents is Home. A definite + // empty session redirects unless the URL explicitly asks for a project or + // a fresh draft; an unknowable session (first run, legacy blob, backend + // unreachable) falls through and the editor boots from what it can find. + // + // Only actual entries are checked: search-only changes ('stay', e.g. the + // session controller stripping ?new before the draft has autosaved) must + // not re-evaluate a blob that is still catching up, and hover preloads + // should not hit the session endpoint at all. + if (cause !== 'enter' || search.project || search.new) { + return; + } + + const openProjectIds = await peekOpenProjectIds(); + + if (openProjectIds !== null && openProjectIds.length === 0) { + throw redirect({ to: '/' }); + } + }, + component: lazyRouteComponent(() => import('./WorkbenchApp'), 'WorkbenchApp'), + getParentRoute: () => authenticatedRoute, + path: '/app', + validateSearch: (search: Record): WorkbenchSearch => ({ + new: search.new === true || search.new === 'true' || search.new === 1 ? true : undefined, + project: typeof search.project === 'string' && search.project.length > 0 ? search.project : undefined, + }), +}); + +const loginRoute = createRoute({ + beforeLoad: async () => { + const session = await ensureAuthSession(); + + if (session.multiuserEnabled && session.setupRequired) { + throw redirect({ to: '/setup' }); + } + + if (!session.multiuserEnabled || session.user !== null) { + throw redirect({ to: '/' }); + } + }, + component: LoginScreen, + getParentRoute: () => rootRoute, + path: '/login', +}); + +const setupRoute = createRoute({ + beforeLoad: async () => { + const session = await ensureAuthSession(); + + if (!session.multiuserEnabled || !session.setupRequired) { + throw redirect({ to: '/' }); + } + }, + component: SetupScreen, + getParentRoute: () => rootRoute, + path: '/setup', +}); + +const RouterPending = () => ; + +export const router = createRouter({ + defaultNotFoundComponent: () => , + defaultPendingComponent: RouterPending, + defaultPreload: 'intent', + history: createHashHistory(), + routeTree: rootRoute.addChildren([ + authenticatedRoute.addChildren([ + homeRoute, + projectsHomeRoute, + modelsHomeRoute, + nodesHomeRoute, + usersHomeRoute, + workbenchRoute, + ]), + loginRoute, + setupRoute, + ]), +}); + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; + } +} diff --git a/invokeai/frontend/webv2/src/theme/__fixtures__/legacyTokenBaseline.json b/invokeai/frontend/webv2/src/theme/__fixtures__/legacyTokenBaseline.json new file mode 100644 index 00000000000..906dba0ba17 --- /dev/null +++ b/invokeai/frontend/webv2/src/theme/__fixtures__/legacyTokenBaseline.json @@ -0,0 +1,237 @@ +{ + "classic": { + "bg": "oklch(21.074% 0.0087 264.37)", + "bg.subtle": "oklch(26.279% 0.0123 264.34)", + "bg.muted": "oklch(31.237% 0.0157 264.32)", + "bg.panel": "oklch(31.237% 0.0157 264.32)", + "bg.emphasized": "oklch(36.004% 0.019 264.3)", + "bg.inset": "oklch(21.074% 0.0087 264.37)", + "bg.error": "color-mix(in oklab, oklch(70.61% 0.0841 19.38) 14%, oklch(26.279% 0.0123 264.34))", + "bg.success": "color-mix(in oklab, oklch(79.8% 0.1132 141.63) 14%, oklch(26.279% 0.0123 264.34))", + "bg.warning": "color-mix(in oklab, oklch(76.62% 0.0612 62.9) 14%, oklch(26.279% 0.0123 264.34))", + "fg": "oklch(96.017% 0.0029 264.54)", + "fg.muted": "oklch(73.736% 0.0202 264.44)", + "fg.subtle": "oklch(57.965% 0.0337 264.27)", + "fg.grid": "oklch(40.619% 0.0221 264.29)", + "fg.error": "oklch(70.61% 0.0841 19.38)", + "fg.success": "oklch(79.8% 0.1132 141.63)", + "fg.warning": "oklch(76.62% 0.0612 62.9)", + "border": "oklch(40.619% 0.0221 264.29)", + "border.subtle": "oklch(40.619% 0.0221 264.29)", + "border.muted": "oklch(40.619% 0.0221 264.29)", + "border.emphasized": "oklch(45.106% 0.0251 264.28)", + "border.error": "oklch(70.61% 0.0841 19.38)", + "gray.contrast": "oklch(21.074% 0.0087 264.37)", + "gray.fg": "oklch(96.017% 0.0029 264.54)", + "gray.subtle": "oklch(31.237% 0.0157 264.32)", + "gray.muted": "oklch(36.004% 0.019 264.3)", + "gray.emphasized": "oklch(45.106% 0.0251 264.28)", + "gray.solid": "oklch(96.017% 0.0029 264.54)", + "gray.focusRing": "oklch(77.738% 0.1 231.76)", + "gray.border": "oklch(45.106% 0.0251 264.28)", + "brand.solid": "oklch(92.041% 0.2103 116.59)", + "brand.contrast": "oklch(21.074% 0.0087 264.37)", + "brand.fg": "oklch(92.041% 0.2103 116.59)", + "brand.subtle": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 16%, oklch(26.279% 0.0123 264.34))", + "brand.muted": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 26%, oklch(26.279% 0.0123 264.34))", + "brand.emphasized": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 36%, oklch(26.279% 0.0123 264.34))", + "brand.focusRing": "oklch(77.738% 0.1 231.76)", + "brand.border": "oklch(92.041% 0.2103 116.59)", + "accent.solid": "oklch(77.738% 0.1 231.76)", + "accent.contrast": "oklch(21.074% 0.0087 264.37)", + "accent.fg": "oklch(77.738% 0.1 231.76)", + "accent.subtle": "color-mix(in oklab, oklch(77.738% 0.1 231.76) 16%, oklch(26.279% 0.0123 264.34))", + "accent.muted": "color-mix(in oklab, oklch(77.738% 0.1 231.76) 26%, oklch(26.279% 0.0123 264.34))", + "accent.emphasized": "color-mix(in oklab, oklch(77.738% 0.1 231.76) 36%, oklch(26.279% 0.0123 264.34))", + "accent.focusRing": "oklch(77.738% 0.1 231.76)", + "accent.border": "oklch(77.738% 0.1 231.76)" + }, + "light": { + "bg": "oklch(96% 0.0038 264)", + "bg.subtle": "oklch(99.4% 0.002 264)", + "bg.muted": "oklch(97% 0.003 264)", + "bg.panel": "oklch(97% 0.003 264)", + "bg.emphasized": "oklch(93.5% 0.005 264)", + "bg.inset": "oklch(96% 0.0038 264)", + "bg.error": "color-mix(in oklab, oklch(55.509% 0.1707 24.62) 14%, oklch(99.4% 0.002 264))", + "bg.success": "color-mix(in oklab, oklch(53% 0.15 150) 14%, oklch(99.4% 0.002 264))", + "bg.warning": "color-mix(in oklab, oklch(60% 0.13 72) 14%, oklch(99.4% 0.002 264))", + "fg": "oklch(22.5% 0.013 264)", + "fg.muted": "oklch(45% 0.017 264)", + "fg.subtle": "oklch(63% 0.014 264)", + "fg.grid": "oklch(88.5% 0.0075 264)", + "fg.error": "oklch(55.509% 0.1707 24.62)", + "fg.success": "oklch(53% 0.15 150)", + "fg.warning": "oklch(60% 0.13 72)", + "border": "oklch(90.5% 0.0065 264)", + "border.subtle": "oklch(90.5% 0.0065 264)", + "border.muted": "oklch(90.5% 0.0065 264)", + "border.emphasized": "oklch(84% 0.0095 264)", + "border.error": "oklch(55.509% 0.1707 24.62)", + "gray.contrast": "oklch(96% 0.0038 264)", + "gray.fg": "oklch(22.5% 0.013 264)", + "gray.subtle": "oklch(95.5% 0.005 264)", + "gray.muted": "oklch(93.5% 0.005 264)", + "gray.emphasized": "oklch(84% 0.0095 264)", + "gray.solid": "oklch(22.5% 0.013 264)", + "gray.focusRing": "oklch(54.615% 0.2152 262.88)", + "gray.border": "oklch(84% 0.0095 264)", + "brand.solid": "oklch(92.041% 0.2103 116.59)", + "brand.contrast": "oklch(22.5% 0.013 264)", + "brand.fg": "oklch(92.041% 0.2103 116.59)", + "brand.subtle": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 16%, oklch(99.4% 0.002 264))", + "brand.muted": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 26%, oklch(99.4% 0.002 264))", + "brand.emphasized": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 36%, oklch(99.4% 0.002 264))", + "brand.focusRing": "oklch(54.615% 0.2152 262.88)", + "brand.border": "oklch(92.041% 0.2103 116.59)", + "accent.solid": "oklch(54.615% 0.2152 262.88)", + "accent.contrast": "oklch(100% 0 0)", + "accent.fg": "oklch(54.615% 0.2152 262.88)", + "accent.subtle": "color-mix(in oklab, oklch(54.615% 0.2152 262.88) 16%, oklch(99.4% 0.002 264))", + "accent.muted": "color-mix(in oklab, oklch(54.615% 0.2152 262.88) 26%, oklch(99.4% 0.002 264))", + "accent.emphasized": "color-mix(in oklab, oklch(54.615% 0.2152 262.88) 36%, oklch(99.4% 0.002 264))", + "accent.focusRing": "oklch(54.615% 0.2152 262.88)", + "accent.border": "oklch(54.615% 0.2152 262.88)" + }, + "forest": { + "bg": "oklch(17.349% 0.0154 144.88)", + "bg.subtle": "oklch(19.03% 0.0175 144.85)", + "bg.muted": "oklch(20.99% 0.0219 144.74)", + "bg.panel": "oklch(20.99% 0.0219 144.74)", + "bg.emphasized": "oklch(25.62% 0.03 144.64)", + "bg.inset": "oklch(17.349% 0.0154 144.88)", + "bg.error": "color-mix(in oklab, oklch(69.305% 0.1467 35.44) 14%, oklch(19.03% 0.0175 144.85))", + "bg.success": "color-mix(in oklab, oklch(78% 0.16 148) 14%, oklch(19.03% 0.0175 144.85))", + "bg.warning": "color-mix(in oklab, oklch(80% 0.12 76) 14%, oklch(19.03% 0.0175 144.85))", + "fg": "oklch(90.57% 0.0511 134.45)", + "fg.muted": "oklch(71.619% 0.0575 135.25)", + "fg.subtle": "oklch(53.577% 0.0538 136.73)", + "fg.grid": "oklch(34.825% 0.0526 141.63)", + "fg.error": "oklch(69.305% 0.1467 35.44)", + "fg.success": "oklch(78% 0.16 148)", + "fg.warning": "oklch(80% 0.12 76)", + "border": "oklch(27.425% 0.0354 143.04)", + "border.subtle": "oklch(27.425% 0.0354 143.04)", + "border.muted": "oklch(27.425% 0.0354 143.04)", + "border.emphasized": "oklch(36.133% 0.0575 141.06)", + "border.error": "oklch(69.305% 0.1467 35.44)", + "gray.contrast": "oklch(17.349% 0.0154 144.88)", + "gray.fg": "oklch(90.57% 0.0511 134.45)", + "gray.subtle": "oklch(28.489% 0.0483 141.22)", + "gray.muted": "oklch(25.62% 0.03 144.64)", + "gray.emphasized": "oklch(36.133% 0.0575 141.06)", + "gray.solid": "oklch(90.57% 0.0511 134.45)", + "gray.focusRing": "oklch(70.437% 0.1101 178.23)", + "gray.border": "oklch(36.133% 0.0575 141.06)", + "brand.solid": "oklch(79.714% 0.1784 136.37)", + "brand.contrast": "oklch(18.298% 0.0314 147.69)", + "brand.fg": "oklch(79.714% 0.1784 136.37)", + "brand.subtle": "color-mix(in oklab, oklch(79.714% 0.1784 136.37) 16%, oklch(19.03% 0.0175 144.85))", + "brand.muted": "color-mix(in oklab, oklch(79.714% 0.1784 136.37) 26%, oklch(19.03% 0.0175 144.85))", + "brand.emphasized": "color-mix(in oklab, oklch(79.714% 0.1784 136.37) 36%, oklch(19.03% 0.0175 144.85))", + "brand.focusRing": "oklch(70.437% 0.1101 178.23)", + "brand.border": "oklch(79.714% 0.1784 136.37)", + "accent.solid": "oklch(70.437% 0.1101 178.23)", + "accent.contrast": "oklch(17.44% 0.0258 171.89)", + "accent.fg": "oklch(70.437% 0.1101 178.23)", + "accent.subtle": "color-mix(in oklab, oklch(70.437% 0.1101 178.23) 16%, oklch(19.03% 0.0175 144.85))", + "accent.muted": "color-mix(in oklab, oklch(70.437% 0.1101 178.23) 26%, oklch(19.03% 0.0175 144.85))", + "accent.emphasized": "color-mix(in oklab, oklch(70.437% 0.1101 178.23) 36%, oklch(19.03% 0.0175 144.85))", + "accent.focusRing": "oklch(70.437% 0.1101 178.23)", + "accent.border": "oklch(70.437% 0.1101 178.23)" + }, + "mono": { + "bg": "oklch(18.22% 0 0)", + "bg.subtle": "oklch(20.019% 0 0)", + "bg.muted": "oklch(22.213% 0 0)", + "bg.panel": "oklch(22.213% 0 0)", + "bg.emphasized": "oklch(27.274% 0 0)", + "bg.inset": "oklch(18.22% 0 0)", + "bg.error": "color-mix(in oklab, oklch(71.115% 0.0934 19.64) 14%, oklch(20.019% 0 0))", + "bg.success": "color-mix(in oklab, oklch(76% 0.06 150) 14%, oklch(20.019% 0 0))", + "bg.warning": "color-mix(in oklab, oklch(79% 0.07 80) 14%, oklch(20.019% 0 0))", + "fg": "oklch(93.1% 0 0)", + "fg.muted": "oklch(71.547% 0 0)", + "fg.subtle": "oklch(53.824% 0 0)", + "fg.grid": "oklch(32.897% 0 0)", + "fg.error": "oklch(71.115% 0.0934 19.64)", + "fg.success": "oklch(76% 0.06 150)", + "fg.warning": "oklch(79% 0.07 80)", + "border": "oklch(28.908% 0 0)", + "border.subtle": "oklch(28.908% 0 0)", + "border.muted": "oklch(28.908% 0 0)", + "border.emphasized": "oklch(34.846% 0 0)", + "border.error": "oklch(71.115% 0.0934 19.64)", + "gray.contrast": "oklch(18.22% 0 0)", + "gray.fg": "oklch(93.1% 0 0)", + "gray.subtle": "oklch(28.908% 0 0)", + "gray.muted": "oklch(27.274% 0 0)", + "gray.emphasized": "oklch(34.846% 0 0)", + "gray.solid": "oklch(93.1% 0 0)", + "gray.focusRing": "oklch(68.622% 0 0)", + "gray.border": "oklch(34.846% 0 0)", + "brand.solid": "oklch(93.1% 0 0)", + "brand.contrast": "oklch(18.22% 0 0)", + "brand.fg": "oklch(93.1% 0 0)", + "brand.subtle": "color-mix(in oklab, oklch(93.1% 0 0) 16%, oklch(20.019% 0 0))", + "brand.muted": "color-mix(in oklab, oklch(93.1% 0 0) 26%, oklch(20.019% 0 0))", + "brand.emphasized": "color-mix(in oklab, oklch(93.1% 0 0) 36%, oklch(20.019% 0 0))", + "brand.focusRing": "oklch(68.622% 0 0)", + "brand.border": "oklch(93.1% 0 0)", + "accent.solid": "oklch(68.622% 0 0)", + "accent.contrast": "oklch(18.22% 0 0)", + "accent.fg": "oklch(68.622% 0 0)", + "accent.subtle": "color-mix(in oklab, oklch(68.622% 0 0) 16%, oklch(20.019% 0 0))", + "accent.muted": "color-mix(in oklab, oklch(68.622% 0 0) 26%, oklch(20.019% 0 0))", + "accent.emphasized": "color-mix(in oklab, oklch(68.622% 0 0) 36%, oklch(20.019% 0 0))", + "accent.focusRing": "oklch(68.622% 0 0)", + "accent.border": "oklch(68.622% 0 0)" + }, + "ultradark": { + "bg": "oklch(0% 0 0)", + "bg.subtle": "oklch(11.492% 0 0)", + "bg.muted": "oklch(14.479% 0 0)", + "bg.panel": "oklch(14.479% 0 0)", + "bg.emphasized": "oklch(19.125% 0 0)", + "bg.inset": "oklch(0% 0 0)", + "bg.error": "color-mix(in oklab, oklch(71.161% 0.1812 22.84) 14%, oklch(11.492% 0 0))", + "bg.success": "color-mix(in oklab, oklch(76.5% 0.16 150.5) 14%, oklch(11.492% 0 0))", + "bg.warning": "color-mix(in oklab, oklch(79.5% 0.13 80) 14%, oklch(11.492% 0 0))", + "fg": "oklch(88.901% 0.0317 120.83)", + "fg.muted": "oklch(66.382% 0.0192 131.96)", + "fg.subtle": "oklch(47.041% 0.0184 127.12)", + "fg.grid": "oklch(23.929% 0 0)", + "fg.error": "oklch(71.161% 0.1812 22.84)", + "fg.success": "oklch(76.5% 0.16 150.5)", + "fg.warning": "oklch(79.5% 0.13 80)", + "border": "oklch(20.904% 0 0)", + "border.subtle": "oklch(20.904% 0 0)", + "border.muted": "oklch(20.904% 0 0)", + "border.emphasized": "oklch(26.862% 0 0)", + "border.error": "oklch(71.161% 0.1812 22.84)", + "gray.contrast": "oklch(0% 0 0)", + "gray.fg": "oklch(88.901% 0.0317 120.83)", + "gray.subtle": "oklch(21.779% 0 0)", + "gray.muted": "oklch(19.125% 0 0)", + "gray.emphasized": "oklch(26.862% 0 0)", + "gray.solid": "oklch(88.901% 0.0317 120.83)", + "gray.focusRing": "oklch(80.623% 0.1248 228.24)", + "gray.border": "oklch(26.862% 0 0)", + "brand.solid": "oklch(93.444% 0.19 125.56)", + "brand.contrast": "oklch(15.913% 0.0233 128.66)", + "brand.fg": "oklch(93.444% 0.19 125.56)", + "brand.subtle": "color-mix(in oklab, oklch(93.444% 0.19 125.56) 16%, oklch(11.492% 0 0))", + "brand.muted": "color-mix(in oklab, oklch(93.444% 0.19 125.56) 26%, oklch(11.492% 0 0))", + "brand.emphasized": "color-mix(in oklab, oklch(93.444% 0.19 125.56) 36%, oklch(11.492% 0 0))", + "brand.focusRing": "oklch(80.623% 0.1248 228.24)", + "brand.border": "oklch(93.444% 0.19 125.56)", + "accent.solid": "oklch(80.623% 0.1248 228.24)", + "accent.contrast": "oklch(17.416% 0.0256 235.84)", + "accent.fg": "oklch(80.623% 0.1248 228.24)", + "accent.subtle": "color-mix(in oklab, oklch(80.623% 0.1248 228.24) 16%, oklch(11.492% 0 0))", + "accent.muted": "color-mix(in oklab, oklch(80.623% 0.1248 228.24) 26%, oklch(11.492% 0 0))", + "accent.emphasized": "color-mix(in oklab, oklch(80.623% 0.1248 228.24) 36%, oklch(11.492% 0 0))", + "accent.focusRing": "oklch(80.623% 0.1248 228.24)", + "accent.border": "oklch(80.623% 0.1248 228.24)" + } +} diff --git a/invokeai/frontend/webv2/src/theme/__tokenResolve.ts b/invokeai/frontend/webv2/src/theme/__tokenResolve.ts new file mode 100644 index 00000000000..0bdd9a12b38 --- /dev/null +++ b/invokeai/frontend/webv2/src/theme/__tokenResolve.ts @@ -0,0 +1,130 @@ +/** + * Test-only helper: resolve every semantic color token to its FINAL literal value + * (following `var(--chakra-colors-…)` references) for each workbench theme, straight + * from `system.getTokenCss()`. This mirrors what the browser computes for a given + * ``, so it is the ground truth used by the + * legacy-value gate in `system.test.ts`. + * + * Not imported by any app entry — excluded from the production bundle. + */ + +type Block = Record; +type Layer = Record; + +export interface TokenSystem { + getTokenCss: () => Record; + tokens: { getByName: (name: string) => { extensions: { cssVar: { var: string } } } | undefined }; +} + +const BASE_SEL = '&:where(html, .chakra-theme)'; +const DARK_SEL = '.dark &, .dark .chakra-theme:not(.light) &'; +const LIGHT_SEL = ':root &, .light &'; +const dataSel = (id: string): string => `&:root[data-theme=${id}]`; + +/** Selectors that apply to each theme, HIGHEST CSS priority first. */ +export const THEME_SELECTORS: Record = { + classic: [DARK_SEL, BASE_SEL], // default theme: no [data-theme=classic] rule + light: [dataSel('light'), LIGHT_SEL, BASE_SEL], + forest: [dataSel('forest'), DARK_SEL, BASE_SEL], + mono: [dataSel('mono'), DARK_SEL, BASE_SEL], + ultradark: [dataSel('ultradark'), DARK_SEL, BASE_SEL], +}; + +export const THEMES = Object.keys(THEME_SELECTORS); + +const VAR_REF = /var\((--chakra-colors-[A-Za-z0-9\\.-]+)\)/g; + +const layerOf = (sys: TokenSystem): Layer => sys.getTokenCss()['@layer tokens']; + +const resolveVar = (layer: Layer, theme: string, varName: string, seen: Set): string | undefined => { + for (const sel of THEME_SELECTORS[theme]) { + const block = layer[sel]; + if (block && varName in block) { + return resolveValue(layer, theme, block[varName], seen); + } + } + return undefined; +}; + +const resolveValue = (layer: Layer, theme: string, value: string, seen: Set): string => + value.replace(VAR_REF, (whole, varName: string) => { + if (seen.has(varName)) { + return whole; + } + const resolved = resolveVar(layer, theme, varName, new Set(seen).add(varName)); + return resolved ?? whole; + }); + +/** Final literal value of `colors.` in `theme`. */ +export const resolveToken = (sys: TokenSystem, theme: string, tokenName: string): string => { + const token = sys.tokens.getByName(`colors.${tokenName}`); + if (!token) { + throw new Error(`token not found: colors.${tokenName}`); + } + const value = resolveVar(layerOf(sys), theme, token.extensions.cssVar.var, new Set()); + if (value === undefined) { + throw new Error(`could not resolve colors.${tokenName} for theme ${theme}`); + } + return value; +}; + +/** Resolve every token across every theme → { [theme]: { [token]: value } }. */ +export const resolveAll = (sys: TokenSystem, tokens: readonly string[]): Record> => { + const out: Record> = {}; + for (const theme of THEMES) { + out[theme] = {}; + for (const token of tokens) { + out[theme][token] = resolveToken(sys, theme, token); + } + } + return out; +}; + +/** The frozen public color-token contract — every name a component/recipe may consume. */ +export const CONSUMER_TOKENS = [ + 'bg', + 'bg.subtle', + 'bg.muted', + 'bg.panel', + 'bg.emphasized', + 'bg.inset', + 'bg.error', + 'bg.success', + 'bg.warning', + 'fg', + 'fg.muted', + 'fg.subtle', + 'fg.grid', + 'fg.error', + 'fg.success', + 'fg.warning', + 'border', + 'border.subtle', + 'border.muted', + 'border.emphasized', + 'border.error', + 'gray.contrast', + 'gray.fg', + 'gray.subtle', + 'gray.muted', + 'gray.emphasized', + 'gray.solid', + 'gray.focusRing', + 'gray.border', + 'brand.solid', + 'brand.contrast', + 'brand.fg', + 'brand.subtle', + 'brand.muted', + 'brand.emphasized', + 'brand.focusRing', + 'brand.border', + 'accent.solid', + 'accent.contrast', + 'accent.fg', + 'accent.subtle', + 'accent.muted', + 'accent.emphasized', + 'accent.focusRing', + 'accent.border', +] as const; diff --git a/invokeai/frontend/webv2/src/theme/recipes.ts b/invokeai/frontend/webv2/src/theme/recipes.ts new file mode 100644 index 00000000000..a3ca67514b1 --- /dev/null +++ b/invokeai/frontend/webv2/src/theme/recipes.ts @@ -0,0 +1,361 @@ +import { defineRecipe, defineSlotRecipe } from '@chakra-ui/react'; +import { slotRecipes as chakraSlotRecipes } from '@chakra-ui/react/theme'; + +/** + * Reusable, theme-aware recipes for the workbench design system. + * + * Recipes reference semantic tokens only, so every variant automatically tracks + * the active theme. Two kinds live here: + * + * 1. Overrides for Chakra's built-in component recipes (`tooltip`, `menu`, + * `select`, `combobox`, `dialog`) — registered in `system.ts` so every + * instance app-wide gets the workbench chrome with zero props at the call + * site. + * 2. Workbench-specific recipes (`panel`, `row`, `chip`, `fieldLabel`, + * `themeCard`) — consumed via the wrappers in `workbench/components/ui` + * with `useRecipe({ recipe })` / `useSlotRecipe({ recipe })`, which keeps + * them fully typed without the Chakra typegen step. + * + * Either way, this file is the single place where shared component styling is + * edited. + */ + +/* -------------------------------------------------------------------------- * + * Built-in component overrides (registered in system.ts) + * -------------------------------------------------------------------------- */ + +/** Tooltip chrome: raised surface with a hairline stroke instead of inverted fill. */ +export const tooltipSlotRecipe = defineSlotRecipe({ + slots: ['content', 'arrowTip'], + base: { + content: { + '--tooltip-bg': 'colors.bg.muted', + bg: 'var(--tooltip-bg)', + borderColor: 'border.emphasized', + borderWidth: '1px', + boxShadow: 'lg', + color: 'fg', + }, + arrowTip: { + borderColor: 'border.emphasized', + }, + }, +}); + +const dropdownContent = { + bg: 'bg.muted', + borderColor: 'border.emphasized', + borderRadius: 'lg', + borderWidth: '1px', + boxShadow: 'lg', + color: 'fg', +}; + +const dropdownItem = { + _highlighted: { bg: 'bg.subtle' }, + _focusVisible: { + outline: '2px solid', + outlineColor: 'accent.solid', + outlineOffset: '-2px', + }, +}; + +const dropdownGroupLabel = { + color: 'fg.subtle', + fontSize: '2xs', + fontWeight: '600', + letterSpacing: '0.02em', + textTransform: 'uppercase', +}; + +/** Menu / context-menu chrome: popover surface with an emphasized stroke. */ +export const menuSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.menu, + base: { + ...chakraSlotRecipes.menu.base, + content: { + ...chakraSlotRecipes.menu.base?.content, + ...dropdownContent, + }, + item: { + ...chakraSlotRecipes.menu.base?.item, + ...dropdownItem, + }, + itemGroupLabel: { + ...chakraSlotRecipes.menu.base?.itemGroupLabel, + ...dropdownGroupLabel, + }, + separator: { + ...chakraSlotRecipes.menu.base?.separator, + bg: 'border.subtle', + }, + }, + defaultVariants: { + ...chakraSlotRecipes.menu.defaultVariants, + size: 'sm', + }, +}); + +/** Select dropdown chrome: same surface and item treatment as menus. */ +export const selectSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.select, + base: { + ...chakraSlotRecipes.select.base, + content: { + ...chakraSlotRecipes.select.base?.content, + ...dropdownContent, + }, + item: { + ...chakraSlotRecipes.select.base?.item, + ...dropdownItem, + }, + itemGroupLabel: { + ...chakraSlotRecipes.select.base?.itemGroupLabel, + ...dropdownGroupLabel, + }, + }, +}); + +/** Combobox chrome: kept aligned with Select for future searchable fields. */ +export const comboboxSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.combobox, + base: { + ...chakraSlotRecipes.combobox.base, + content: { + ...chakraSlotRecipes.combobox.base?.content, + ...dropdownContent, + }, + input: { + ...chakraSlotRecipes.combobox.base?.input, + _hover: { borderColor: 'border.emphasized' }, + }, + item: { + ...chakraSlotRecipes.combobox.base?.item, + ...dropdownItem, + }, + itemGroupLabel: { + ...chakraSlotRecipes.combobox.base?.itemGroupLabel, + ...dropdownGroupLabel, + }, + }, +}); + +/** Dialog chrome: panel surface with a hairline stroke. */ +export const dialogSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.dialog, + base: { + ...chakraSlotRecipes.dialog.base, + content: { + ...chakraSlotRecipes.dialog.base?.content, + borderColor: 'border.subtle', + borderWidth: '1px', + }, + }, +}); + +/** Slider marks: compact auxiliary labels for dense widget controls. */ +export const sliderSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.slider, + base: { + ...chakraSlotRecipes.slider.base, + markerLabel: { + ...chakraSlotRecipes.slider.base?.markerLabel, + color: 'fg.subtle', + fontSize: '0.5rem', + lineHeight: '1', + }, + }, +}); + +/** Progress circle: add a compact icon-sized variant for dense chrome like tabs. */ +export const progressCircleSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.progressCircle, + variants: { + ...chakraSlotRecipes.progressCircle.variants, + size: { + ...chakraSlotRecipes.progressCircle.variants?.size, + '2xs': { + circle: { + '--size': '16px', + '--thickness': '3px', + }, + valueText: { + textStyle: '2xs', + }, + }, + }, + }, +}); + +/* -------------------------------------------------------------------------- * + * Workbench recipes (consumed through workbench/components/ui wrappers) + * -------------------------------------------------------------------------- */ + +/** Bordered surface container — panels, cards, wells. */ +export const panelRecipe = defineRecipe({ + base: { + bg: 'bg.subtle', + borderColor: 'border.subtle', + borderRadius: 'md', + borderWidth: '1px', + display: 'flex', + flexDirection: 'column', + minH: '0', + minW: '0', + }, + variants: { + tone: { + surface: {}, + raised: { bg: 'bg.muted' }, + inset: { bg: 'bg.inset' }, + control: { bg: 'bg.emphasized', borderColor: 'transparent' }, + }, + density: { + none: {}, + sm: { gap: '1.5', p: '2' }, + md: { gap: '2', p: '3' }, + }, + }, + defaultVariants: { tone: 'surface', density: 'none' }, +}); + +/** Interactive list / table row with hover, focus, and active fills. */ +export const rowRecipe = defineRecipe({ + base: { + alignItems: 'center', + borderRadius: 'sm', + cursor: 'pointer', + display: 'flex', + gap: '2', + textAlign: 'start', + transition: 'background var(--wb-motion-duration-fast) ease, color var(--wb-motion-duration-fast) ease', + w: 'full', + _hover: { bg: 'bg.muted' }, + _focusVisible: { + outline: '2px solid', + outlineColor: 'accent.solid', + outlineOffset: '-2px', + }, + _disabled: { cursor: 'not-allowed', opacity: 0.5 }, + }, + variants: { + active: { + none: {}, + muted: { bg: 'bg.muted' }, + brand: { + bg: 'brand.subtle', + color: 'brand.fg', + _hover: { bg: 'brand.subtle' }, + }, + accent: { + bg: 'accent.solid', + color: 'accent.contrast', + _hover: { bg: 'accent.solid' }, + }, + }, + }, + defaultVariants: { active: 'none' }, +}); + +/** Compact status chip with intent tones — queue counts, server state, versions. */ +export const chipRecipe = defineRecipe({ + base: { + alignItems: 'center', + borderRadius: 'sm', + display: 'inline-flex', + flexShrink: '0', + fontSize: '2xs', + fontWeight: '500', + gap: '1.5', + px: '2', + py: '0.5', + whiteSpace: 'nowrap', + }, + variants: { + tone: { + neutral: {}, + brand: { bg: 'brand.subtle', color: 'brand.fg' }, + accent: { color: 'accent.solid' }, + error: { color: 'fg.error' }, + success: { color: 'fg.success' }, + warning: { color: 'fg.warning' }, + }, + }, + defaultVariants: { tone: 'neutral' }, +}); + +/** Compact uppercase field label shared across widget forms and the settings modal. */ +export const fieldLabelRecipe = defineRecipe({ + base: { + color: 'fg.muted', + fontSize: '2xs', + fontWeight: '600', + letterSpacing: '0.03em', + }, +}); + +/** Selectable theme swatch card used by the Settings appearance picker. */ +export const themeCardRecipe = defineSlotRecipe({ + slots: ['root', 'preview', 'swatch', 'body', 'name', 'description', 'indicator'], + base: { + root: { + alignItems: 'stretch', + bg: 'bg.subtle', + borderColor: 'border.subtle', + borderRadius: 'lg', + borderWidth: '1px', + cursor: 'pointer', + display: 'flex', + flexDirection: 'column', + gap: '2.5', + overflow: 'hidden', + p: '3', + textAlign: 'left', + transition: + 'border-color var(--wb-motion-duration-fast) ease, background var(--wb-motion-duration-fast) ease, transform var(--wb-motion-duration-fast) ease', + _hover: { borderColor: 'border.emphasized' }, + _focusVisible: { + outline: '2px solid', + outlineColor: 'accent.solid', + outlineOffset: '2px', + }, + }, + preview: { + borderColor: 'border.subtle', + borderRadius: 'md', + borderWidth: '1px', + display: 'flex', + h: '8', + overflow: 'hidden', + }, + swatch: { flex: '1' }, + body: { + alignItems: 'flex-start', + display: 'flex', + flexDirection: 'column', + gap: '0.5', + }, + name: { color: 'fg', fontSize: 'sm', fontWeight: '600' }, + description: { color: 'fg.subtle', fontSize: '2xs', lineHeight: '1.3' }, + indicator: { + alignItems: 'center', + borderRadius: 'full', + color: 'accent.solid', + display: 'flex', + h: '4', + justifyContent: 'center', + opacity: 0, + w: '4', + }, + }, + variants: { + selected: { + true: { + root: { borderColor: 'accent.solid', bg: 'bg.muted' }, + indicator: { opacity: 1 }, + }, + false: {}, + }, + }, + defaultVariants: { selected: false }, +}); diff --git a/invokeai/frontend/webv2/src/theme/system.test.ts b/invokeai/frontend/webv2/src/theme/system.test.ts new file mode 100644 index 00000000000..a4c76d9de1b --- /dev/null +++ b/invokeai/frontend/webv2/src/theme/system.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; + +import legacyBaseline from './__fixtures__/legacyTokenBaseline.json'; +import { CONSUMER_TOKENS, resolveToken, THEMES, type TokenSystem } from './__tokenResolve'; +import { progressCircleSlotRecipe } from './recipes'; +import { system } from './system'; + +const sys = system as unknown as TokenSystem; +const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const; + +const lightnessOf = (oklch: string): number => { + const match = /oklch\(\s*([\d.]+)%/.exec(oklch); + if (!match) { + throw new Error(`not a plain oklch value: ${oklch}`); + } + return Number(match[1]); +}; + +describe('color token contract — legacy-value gate', () => { + // Primary gate: every consumer token must resolve to the EXACT value it had + // before the ramp refactor. The baseline was captured from the previous system + // on the working tree before any change (see __fixtures__/legacyTokenBaseline.json). + for (const theme of THEMES) { + for (const token of CONSUMER_TOKENS) { + it(`${theme}: ${token} is unchanged`, () => { + const expected = (legacyBaseline as Record>)[theme][token]; + expect(resolveToken(sys, theme, token)).toBe(expected); + }); + } + } +}); + +describe('ramp + mapping structure', () => { + it('emits every neutral ramp step for every theme', () => { + for (const theme of THEMES) { + for (const step of STEPS) { + expect(resolveToken(sys, theme, `neutral.${step}`)).toMatch(/^oklch\(/); + } + } + }); + + it('maps the light surface ladder to the corrected (non-mirrored) steps', () => { + // The point-1 fix: light bg sits at neutral.200, not the lightest step. + expect(resolveToken(sys, 'light', 'bg')).toBe(resolveToken(sys, 'light', 'neutral.200')); + expect(resolveToken(sys, 'light', 'bg.subtle')).toBe(resolveToken(sys, 'light', 'neutral.50')); + expect(resolveToken(sys, 'light', 'bg.muted')).toBe(resolveToken(sys, 'light', 'neutral.100')); + expect(resolveToken(sys, 'light', 'border')).toBe(resolveToken(sys, 'light', 'neutral.300')); + expect(resolveToken(sys, 'light', 'border.emphasized')).toBe(resolveToken(sys, 'light', 'neutral.400')); + }); + + it('maps the dark surface ladder onto the ramp', () => { + expect(resolveToken(sys, 'classic', 'bg')).toBe(resolveToken(sys, 'classic', 'neutral.950')); + expect(resolveToken(sys, 'classic', 'bg.subtle')).toBe(resolveToken(sys, 'classic', 'neutral.900')); + expect(resolveToken(sys, 'classic', 'fg')).toBe(resolveToken(sys, 'classic', 'neutral.50')); + expect(resolveToken(sys, 'classic', 'border')).toBe(resolveToken(sys, 'classic', 'neutral.600')); + }); + + it('emits surface/text/border tokens via per-theme conditions only — never the leaky mode selectors', () => { + // Chakra's `_light` selector (`:root &, .light &`) matches under EVERY theme via its + // `:root &` arm. A surface token placed there leaks its light value into the dark + // themes (the classic-renders-light regression). These tokens must live ONLY in the + // base + `[data-theme=…]` selectors. + const layer = sys.getTokenCss()['@layer tokens']; + const LEAKY = [':root &, .light &', '.dark &, .dark .chakra-theme:not(.light) &']; + const surfaceTokens = [ + 'bg', + 'bg.subtle', + 'bg.muted', + 'bg.panel', + 'fg', + 'fg.muted', + 'fg.subtle', + 'border', + 'border.subtle', + 'border.muted', + 'border.emphasized', + ]; + for (const name of surfaceTokens) { + const token = sys.tokens.getByName(`colors.${name}`); + const cssVar = token?.extensions.cssVar.var ?? ''; + for (const selector of LEAKY) { + expect(layer[selector]?.[cssVar], `${name} must not be emitted under "${selector}"`).toBeUndefined(); + } + } + }); + + it('keeps each ramp strictly decreasing in lightness 50 → 950', () => { + for (const theme of THEMES) { + const ls = STEPS.map((step) => lightnessOf(resolveToken(sys, theme, `neutral.${step}`))); + for (let i = 1; i < ls.length; i++) { + expect(ls[i], `${theme} neutral.${STEPS[i]} should be darker than neutral.${STEPS[i - 1]}`).toBeLessThan( + ls[i - 1] + ); + } + } + }); +}); + +describe('native Chakra integration', () => { + it('adds an icon-sized progress circle variant', () => { + expect(progressCircleSlotRecipe.variants?.size?.['2xs']).toMatchObject({ + circle: { '--size': '16px', '--thickness': '3px' }, + }); + }); + + it('resolves every gray virtual-palette key under both dark and light', () => { + const keys = ['contrast', 'fg', 'subtle', 'muted', 'emphasized', 'solid', 'focusRing', 'border']; + for (const theme of ['classic', 'light']) { + for (const key of keys) { + expect(resolveToken(sys, theme, `gray.${key}`)).toBeTruthy(); + } + } + }); + + it('leaves stock Chakra hue palettes untouched in Phase 1', () => { + // Phase 2 will theme these; for now they must remain Chakra defaults so model + // badges / destructive actions keep their stock colors. + expect(sys.tokens.getByName('colors.red.500')).toBeTruthy(); + expect(sys.tokens.getByName('colors.blue.500')).toBeTruthy(); + expect(sys.tokens.getByName('colors.purple.500')).toBeTruthy(); + }); +}); + +describe('brand palette derives from its two seeds (like accent)', () => { + it('fg/border equal solid; subtle/muted/emphasized mix solid into the surface', () => { + for (const theme of THEMES) { + const solid = resolveToken(sys, theme, 'brand.solid'); + const surface = resolveToken(sys, theme, 'bg.subtle'); + expect(resolveToken(sys, theme, 'brand.fg')).toBe(solid); + expect(resolveToken(sys, theme, 'brand.border')).toBe(solid); + for (const [key, pct] of [ + ['subtle', 16], + ['muted', 26], + ['emphasized', 36], + ] as const) { + expect(resolveToken(sys, theme, `brand.${key}`)).toBe(`color-mix(in oklab, ${solid} ${pct}%, ${surface})`); + } + } + }); +}); diff --git a/invokeai/frontend/webv2/src/theme/system.ts b/invokeai/frontend/webv2/src/theme/system.ts new file mode 100644 index 00000000000..86a769dde05 --- /dev/null +++ b/invokeai/frontend/webv2/src/theme/system.ts @@ -0,0 +1,290 @@ +import { createSystem, defaultConfig, defineConfig } from '@chakra-ui/react'; + +import { + comboboxSlotRecipe, + dialogSlotRecipe, + menuSlotRecipe, + progressCircleSlotRecipe, + selectSlotRecipe, + sliderSlotRecipe, + tooltipSlotRecipe, +} from './recipes'; +import { DEFAULT_THEME, DEFAULT_THEME_ID, type NeutralStep, THEMES, type ThemeDefinition } from './themes'; + +/** + * Workbench design system. + * + * Each theme (`themes.ts`) is authored as one neutral ramp (`neutral.50…neutral.950`, + * lightest → darkest) plus a few seeds (brand, accent, status) and four off-ramp + * neutrals (`inset`, `fill`, `grid`, `control`). This module turns that into two + * token layers: + * + * 1. The **ramp** is emitted verbatim as conditional semantic tokens + * (`neutral.*`), one value per theme keyed on a custom `[data-theme=]` + * condition. Chakra base `tokens.colors` only hold plain strings, so anything + * that varies per theme must live in `semanticTokens`. + * 2. The **semantic contract** (`bg`, `fg.muted`, `border.emphasized`, the + * `gray`/`brand`/`accent` palettes, …) is theme-agnostic: it references ramp + * steps and flips by light/dark mode only. `bg` is `neutral.950` in dark mode and + * `neutral.200` in light — the light ramp is not a mirror of the dark one, because + * light panels go *whiter* than the app background. + * + * `ThemeController` sets `data-theme` (and the `.dark`/`.light` class) on ``, + * so switching themes is a single attribute change with zero React re-render. + * Components reference only the semantic tokens — never the ramp or a theme. + * + * Re-pointing Chakra's neutral `gray` palette at the ramp makes every built-in + * component (Dialog, Menu, Input, Button, Tooltip, Badge) follow the active theme + * with no per-component overrides. + */ + +const NON_DEFAULT_THEMES = THEMES.filter((theme) => theme.id !== DEFAULT_THEME_ID); + +/** `light` -> `themeLight`, `ultradark` -> `themeUltradark`. */ +const conditionName = (id: string): string => `theme${id.charAt(0).toUpperCase()}${id.slice(1)}`; + +type TokenValue = { value: Record }; +type Compute = (theme: ThemeDefinition) => string; + +/** Build a semantic-token value object: default theme as `base`, the rest as `[data-theme]` conditions. */ +const colorToken = (compute: Compute): TokenValue => { + const value: Record = { base: compute(DEFAULT_THEME) }; + for (const theme of NON_DEFAULT_THEMES) { + value[`_${conditionName(theme.id)}`] = compute(theme); + } + return { value }; +}; + +/** Blend `pct`% of one computed color into another — used for derived hover/tint steps. */ +const mix = (top: Compute, pct: number, bottom: Compute): TokenValue => + colorToken((theme) => `color-mix(in oklab, ${top(theme)} ${pct}%, ${bottom(theme)})`); + +const LIGHT_FALLBACK_THEME = THEMES.find((theme) => theme.colorScheme === 'light') ?? DEFAULT_THEME; + +/** + * Like `colorToken`, but additionally shadows Chakra's `_light`/`_dark` + * class-conditional values. Nested palette tokens (`gray.*`) deep-merge with + * `defaultConfig` instead of replacing it, so without these keys the default + * gray values would survive the merge and outrank our zero-specificity `base` + * value whenever `ThemeController` sets the `.dark`/`.light` class. The explicit + * `:root[data-theme=…]` conditions still win over both. + */ +const grayToken = (compute: Compute): TokenValue => { + const token = colorToken(compute); + token.value._light = compute(LIGHT_FALLBACK_THEME); + token.value._dark = compute(DEFAULT_THEME); + return token; +}; + +/** + * A token that reads a ramp step, chosen per theme by `colorScheme`. Emitted as + * per-`[data-theme]` conditions, NOT Chakra's `_light`/`_dark`: the built-in + * `_light` selector is `:root &, .light &`, and its `:root &` arm matches under + * EVERY theme — so a mode-flip token leaks its light value into the dark themes. + * Per-theme conditions sidestep the cascade entirely (this is how the pre-refactor + * system worked, and why dark themes rendered correctly). + */ +const ref = (step: NeutralStep): string => `{colors.neutral.${step}}`; +const stepRef = (darkStep: NeutralStep, lightStep: NeutralStep): TokenValue => + colorToken((theme) => ref(theme.colorScheme === 'light' ? lightStep : darkStep)); + +const STEPS: NeutralStep[] = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]; + +/** The default panel surface of a theme — `bg.subtle`'s step. Used as the floor for tints. */ +const surface: Compute = (theme) => + theme.colorScheme === 'light' ? theme.colors.neutral[50] : theme.colors.neutral[900]; + +// Seed accessors. +const danger: Compute = (theme) => theme.colors.danger; +const success: Compute = (theme) => theme.colors.success; +const warning: Compute = (theme) => theme.colors.warning; +const brandSolid: Compute = (theme) => theme.colors.brand.solid; +const accentSolid: Compute = (theme) => theme.colors.accent.solid; + +/** The neutral ramp, emitted as `neutral.50…neutral.950`, one value per theme. */ +const neutralRamp = Object.fromEntries(STEPS.map((step) => [step, colorToken((theme) => theme.colors.neutral[step])])); + +/** + * The semantic-token contract. Backgrounds/foregrounds/borders reference ramp + * steps (`stepRef`); the four off-ramp neutrals and the status/identity hues read + * their per-theme seed directly. Where a Chakra built-in name exists we use it + * verbatim so built-ins inherit the theme for free. + */ +const semanticColors = { + neutral: neutralRamp, + + // Surface ladder. Light panels are whiter than the app bg, so the light steps + // are not a mirror of the dark ones. + bg: stepRef(950, 200), + 'bg.subtle': stepRef(900, 50), + 'bg.muted': stepRef(800, 100), + 'bg.panel': stepRef(800, 100), + 'bg.emphasized': colorToken((theme) => theme.colors.control), + 'bg.inset': colorToken((theme) => theme.colors.inset), + // Soft status fills for alerts/banners, mixed into the panel surface. + 'bg.error': mix(danger, 14, surface), + 'bg.success': mix(success, 14, surface), + 'bg.warning': mix(warning, 14, surface), + + // Foreground. + fg: stepRef(50, 950), + 'fg.muted': stepRef(300, 700), + 'fg.subtle': stepRef(400, 500), + 'fg.grid': colorToken((theme) => theme.colors.grid), + 'fg.error': colorToken(danger), + 'fg.success': colorToken(success), + 'fg.warning': colorToken(warning), + + // Borders. + border: stepRef(600, 300), + 'border.subtle': stepRef(600, 300), + 'border.muted': stepRef(600, 300), + 'border.emphasized': stepRef(500, 400), + 'border.error': colorToken(danger), + + /** + * Chakra's default `colorPalette` is `gray`; re-pointing its virtual-palette + * keys at the ramp makes every un-palettized component (ghost buttons, menu + * items, badges, …) theme-aware with zero props. + * + * Palette tokens must stay NESTED — the `colorPalette` virtual-token map is + * built from the nested structure and ignores flat dotted keys. Because nesting + * deep-merges with the defaults, every gray key shadows the default + * `_light`/`_dark` values via `grayToken`. + */ + gray: { + contrast: grayToken((theme) => + theme.colorScheme === 'light' ? theme.colors.neutral[200] : theme.colors.neutral[950] + ), + fg: grayToken((theme) => (theme.colorScheme === 'light' ? theme.colors.neutral[950] : theme.colors.neutral[50])), + subtle: grayToken((theme) => theme.colors.fill), + muted: grayToken((theme) => theme.colors.control), + emphasized: grayToken((theme) => + theme.colorScheme === 'light' ? theme.colors.neutral[400] : theme.colors.neutral[500] + ), + solid: grayToken((theme) => (theme.colorScheme === 'light' ? theme.colors.neutral[950] : theme.colors.neutral[50])), + focusRing: grayToken(accentSolid), + border: grayToken((theme) => + theme.colorScheme === 'light' ? theme.colors.neutral[400] : theme.colors.neutral[500] + ), + }, + /** + * Invoke identity palette (lime). Authored from two seeds (`solid` + `contrast`), + * like `accent`; the rest derive. NOTE: `brand.fg` is the bright `solid` fill, so + * `brand.fg` on `brand.subtle` reads well on the dark themes but is low-contrast in + * the light theme — brand is meant for emphasis fills, not body text. + */ + brand: { + solid: colorToken(brandSolid), + contrast: colorToken((theme) => theme.colors.brand.contrast), + fg: colorToken(brandSolid), + subtle: mix(brandSolid, 16, surface), + muted: mix(brandSolid, 26, surface), + emphasized: mix(brandSolid, 36, surface), + focusRing: colorToken(accentSolid), + border: colorToken(brandSolid), + }, + /** Selection / focus palette (blue). Use via `accent.solid` or `colorPalette="accent"`. */ + accent: { + solid: colorToken(accentSolid), + contrast: colorToken((theme) => theme.colors.accent.contrast), + fg: colorToken(accentSolid), + subtle: mix(accentSolid, 16, surface), + muted: mix(accentSolid, 26, surface), + emphasized: mix(accentSolid, 36, surface), + focusRing: colorToken(accentSolid), + border: colorToken(accentSolid), + }, +}; + +// `:root` raises specificity above the `.dark`/`.light` colorScheme classes so +// an explicit theme always beats the class-conditional fallback values. +const themeConditions = Object.fromEntries( + NON_DEFAULT_THEMES.map((theme) => [conditionName(theme.id), `:root[data-theme=${theme.id}]`]) +); + +const motionDurationToken = (base: string): TokenValue => ({ value: { base, _reduceMotion: '1ms' } }); +const motionAnimationToken = (base: string): TokenValue => ({ value: { base, _reduceMotion: 'none' } }); + +const config = defineConfig({ + conditions: { ...themeConditions, reduceMotion: ':root[data-reduce-motion=true]' }, + globalCss: { + 'html, body, #root': { + height: '100%', + }, + body: { + bg: 'bg', + color: 'fg', + fontFamily: 'body', + margin: 0, + minHeight: '720px', + minWidth: '960px', + overflow: 'hidden', + }, + ':root': { + '--wb-motion-duration-fast': '0.12s', + '--wb-motion-duration-medium': '0.15s', + '--wb-motion-duration-slow': '0.2s', + '--wb-motion-animation-iteration-count': 'infinite', + }, + // Keep durations non-zero: Ark presence waits for animation lifecycle events. + // Chakra recipe motion is covered by conditional duration/animation tokens below. + ':root[data-reduce-motion="true"]': { + '--wb-motion-duration-fast': '1ms', + '--wb-motion-duration-medium': '1ms', + '--wb-motion-duration-slow': '1ms', + '--wb-motion-animation-iteration-count': '1', + scrollBehavior: 'auto !important', + }, + ':root[data-reduce-motion="true"] .chakra-skeleton': { + animation: 'none !important', + }, + }, + theme: { + tokens: { + fonts: { + body: { + value: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", + }, + heading: { + value: "Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif", + }, + }, + }, + semanticTokens: { + animations: { + bounce: motionAnimationToken('bounce 1s infinite'), + ping: motionAnimationToken('ping 1s cubic-bezier(0, 0, 0.2, 1) infinite'), + pulse: motionAnimationToken('pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite'), + spin: motionAnimationToken('spin 1s linear infinite'), + }, + colors: semanticColors, + durations: { + fastest: motionDurationToken('50ms'), + faster: motionDurationToken('100ms'), + fast: motionDurationToken('150ms'), + moderate: motionDurationToken('200ms'), + slow: motionDurationToken('300ms'), + slower: motionDurationToken('400ms'), + slowest: motionDurationToken('500ms'), + }, + }, + // Chrome-level overrides for Chakra's built-in components, so popover and + // dialog surfaces are consistent everywhere without per-instance props. + slotRecipes: { + combobox: comboboxSlotRecipe, + dialog: dialogSlotRecipe, + menu: menuSlotRecipe, + progressCircle: progressCircleSlotRecipe, + select: selectSlotRecipe, + slider: sliderSlotRecipe, + tooltip: tooltipSlotRecipe, + }, + }, +}); + +export const system = createSystem(defaultConfig, config); + +/** Theme metadata re-exported so UI can import a single module. */ +export { THEMES, THEMES_BY_ID, DEFAULT_THEME, DEFAULT_THEME_ID, previewSwatches } from './themes'; +export type { ThemeColors, ThemeDefinition, NeutralStep } from './themes'; diff --git a/invokeai/frontend/webv2/src/theme/themes.ts b/invokeai/frontend/webv2/src/theme/themes.ts new file mode 100644 index 00000000000..082823b5b89 --- /dev/null +++ b/invokeai/frontend/webv2/src/theme/themes.ts @@ -0,0 +1,265 @@ +import type { WorkbenchThemeId } from '@workbench/types'; + +/** + * Steps of the neutral ramp. `50` is the lightest, `950` the darkest — the same + * absolute orientation Chakra/Tailwind use, so the ramp can be aliased onto the + * built-in `gray` palette without surprises. + */ +export type NeutralStep = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950; + +/** + * One workbench theme, expressed as a small set of concrete OKLch values. + * + * The bulk of a theme is the `base` ramp — a single neutral scale from which the + * semantic-token layer in `system.ts` derives every background, foreground, and + * border (`bg → neutral.950` in dark mode, `fg → neutral.50`, …). Everything else is a + * handful of seeds: + * + * - `brand` / `accent` — the two identity hues (lime action, blue selection); + * - `danger` / `success` / `warning` — status intents; + * - `inset` / `fill` / `grid` / `control` — four neutrals whose *elevation rank* + * differs from theme to theme, so they cannot sit on a single shared ramp step + * (e.g. `inset` recesses below the app background in the light theme but lifts + * above it in the dark themes). They are kept as explicit per-theme values and + * consumed by name through `bg.inset`, `fg.grid`, `gray.subtle`, `bg.emphasized`. + * + * Adding a theme is therefore: author one ramp + the seeds. No component changes. + */ +export interface ThemeColors { + /** Neutral ramp, lightest (`50`) → darkest (`950`). Source of all bg/fg/border. */ + neutral: Record; + /** Invoke identity (lime). Two seeds; the palette's other steps derive in `system.ts`. */ + brand: { solid: string; contrast: string }; + /** Selection / focus (blue). */ + accent: { solid: string; contrast: string }; + /** Destructive / error intent. */ + danger: string; + /** Positive / success intent. */ + success: string; + /** Caution / warning intent. */ + warning: string; + /** Recessed work-area floor framed by the chrome (`bg.inset`). Off-ramp. */ + inset: string; + /** Subtle neutral fill for hovered/ghost controls (`gray.subtle`). Off-ramp. */ + fill: string; + /** Dot-grid decoration drawn on inset surfaces (`fg.grid`). Off-ramp. */ + grid: string; + /** Control / chip fill inside panels (`bg.emphasized`). Off-ramp. */ + control: string; +} + +export interface ThemeDefinition { + id: WorkbenchThemeId; + label: string; + description: string; + /** Native color-scheme hint for form controls, scrollbars, and ` + + + + + + + + + {t('auth.keepSignedIn')} + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/auth/components/PasswordInput.tsx b/invokeai/frontend/webv2/src/workbench/auth/components/PasswordInput.tsx new file mode 100644 index 00000000000..3dafe2e8b62 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/auth/components/PasswordInput.tsx @@ -0,0 +1,71 @@ +import { Box, HStack, Input, InputGroup, Text } from '@chakra-ui/react'; +import { getPasswordStrength, type PasswordStrength } from '@workbench/auth/schemas'; +import { IconButton } from '@workbench/components/ui'; +import { EyeIcon, EyeOffIcon } from 'lucide-react'; +import { useCallback, useMemo, useState, type ComponentProps } from 'react'; +import { useTranslation } from 'react-i18next'; + +type InputProps = ComponentProps; + +/** Password input with an inline visibility toggle. */ +export const PasswordInput = (props: InputProps) => { + const { t } = useTranslation(); + const [isVisible, setIsVisible] = useState(false); + const handleToggleVisibility = useCallback(() => setIsVisible((current) => !current), []); + const endElement = useMemo( + () => ( + + {isVisible ? : } + + ), + [handleToggleVisibility, isVisible, t] + ); + + return ( + + + + ); +}; + +const STRENGTH_META: Record = { + moderate: { filledSegments: 2, labelKey: 'auth.passwordStrength.moderate', tone: 'fg.warning' }, + strong: { filledSegments: 3, labelKey: 'auth.passwordStrength.strong', tone: 'fg.success' }, + weak: { filledSegments: 1, labelKey: 'auth.passwordStrength.weak', tone: 'fg.error' }, +}; + +/** Three-segment strength readout; renders nothing until the user types. */ +export const PasswordStrengthMeter = ({ password }: { password: string }) => { + const { t } = useTranslation(); + + if (!password) { + return null; + } + + const meta = STRENGTH_META[getPasswordStrength(password)]; + + return ( + + + {[0, 1, 2].map((segment) => ( + + ))} + + + {t(meta.labelKey)} + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/auth/components/ProfileDialog.tsx b/invokeai/frontend/webv2/src/workbench/auth/components/ProfileDialog.tsx new file mode 100644 index 00000000000..6072075dae3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/auth/components/ProfileDialog.tsx @@ -0,0 +1,208 @@ +import { Dialog, HStack, Input, Portal, Stack, Text } from '@chakra-ui/react'; +import { generatePassword, updateCurrentUser, type ProfileUpdateRequest, type UserDTO } from '@workbench/auth/api'; +import { createProfileSchema, PASSWORD_RULES_HINT, type ProfileFormValues } from '@workbench/auth/schemas'; +import { setSessionUser, useAuthSession } from '@workbench/auth/session'; +import { getApiErrorMessage } from '@workbench/backend/http'; +import { Button, CloseButton, Field, FieldLabel } from '@workbench/components/ui'; +import { useZodForm } from '@workbench/models/useZodForm'; +import { useNotify } from '@workbench/useNotify'; +import { WandSparklesIcon } from 'lucide-react'; +import { useCallback, useMemo, useState, type ChangeEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { AuthFormAlert } from './AuthScreen'; +import { PasswordInput, PasswordStrengthMeter } from './PasswordInput'; + +/** Account settings: display name and password change for the signed-in user. */ +export const ProfileDialog = ({ isOpen, onClose, user }: { isOpen: boolean; onClose: () => void; user: UserDTO }) => { + const { t } = useTranslation(); + const handleOpenChange = useCallback( + (event: Dialog.OpenChangeDetails) => { + if (!event.open) { + onClose(); + } + }, + [onClose] + ); + + return ( + + + + + + + + + {t('auth.account')} + + + {t('auth.signedInAs', { email: user.email })} + + + + + + + + + + + + ); +}; + +const ProfileForm = ({ onClose, user }: { onClose: () => void; user: UserDTO }) => { + const { t } = useTranslation(); + const session = useAuthSession(); + const notify = useNotify(); + const [isGenerating, setIsGenerating] = useState(false); + const schema = useMemo(() => createProfileSchema(session.strictPasswordChecking), [session.strictPasswordChecking]); + const initialValues: ProfileFormValues = useMemo( + () => ({ confirmPassword: '', currentPassword: '', displayName: user.display_name ?? '', newPassword: '' }), + [user.display_name] + ); + const form = useZodForm(schema, initialValues); + + const fillGeneratedPassword = useCallback(async () => { + setIsGenerating(true); + + try { + const password = await generatePassword(); + + form.setValue('newPassword', password); + form.setValue('confirmPassword', password); + notify.info(t('users.passwordGenerated'), t('auth.passwordGeneratedDescription')); + } catch (error) { + notify.error(t('users.couldNotGeneratePassword'), getApiErrorMessage(error, t('users.backendRejectedRequest'))); + } finally { + setIsGenerating(false); + } + }, [form, notify, t]); + + const submit = useCallback( + () => + form.handleSubmit(async (values) => { + const changes: ProfileUpdateRequest = {}; + const displayName = values.displayName.trim(); + + if (displayName !== (user.display_name ?? '')) { + changes.display_name = displayName; + } + + if (values.newPassword !== '') { + changes.current_password = values.currentPassword; + changes.new_password = values.newPassword; + } + + if (Object.keys(changes).length === 0) { + onClose(); + return; + } + + try { + const updated = await updateCurrentUser(changes); + + setSessionUser(updated); + } catch (error) { + throw new Error(getApiErrorMessage(error, t('auth.couldNotUpdateAccount'))); + } + + notify.success(t('auth.accountUpdated')); + onClose(); + }), + [form, notify, onClose, t, user.display_name] + ); + const handleDisplayNameChange = useCallback( + (event: ChangeEvent) => form.setValue('displayName', event.target.value), + [form] + ); + const handleCurrentPasswordChange = useCallback( + (event: ChangeEvent) => form.setValue('currentPassword', event.target.value), + [form] + ); + const handleNewPasswordChange = useCallback( + (event: ChangeEvent) => form.setValue('newPassword', event.target.value), + [form] + ); + const handleConfirmPasswordChange = useCallback( + (event: ChangeEvent) => form.setValue('confirmPassword', event.target.value), + [form] + ); + const handleGeneratePassword = useCallback(() => void fillGeneratedPassword(), [fillGeneratedPassword]); + const handleSave = useCallback(() => void submit(), [submit]); + + return ( + <> + + + {form.formError ? : null} + + + + + + {t('auth.changePassword')} + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/auth/components/SessionExpiryGuard.tsx b/invokeai/frontend/webv2/src/workbench/auth/components/SessionExpiryGuard.tsx new file mode 100644 index 00000000000..9f34f706ba0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/auth/components/SessionExpiryGuard.tsx @@ -0,0 +1,21 @@ +import { useNavigate } from '@tanstack/react-router'; +import { useAuthSession } from '@workbench/auth/session'; +import { useEffect } from 'react'; + +/** + * Watches for mid-session token rejection (a 401 on any authenticated request) + * and routes back to the login screen, which explains the expiry. Mounted once + * inside the workbench route; renders nothing. + */ +export const SessionExpiryGuard = () => { + const session = useAuthSession(); + const navigate = useNavigate(); + + useEffect(() => { + if (session.multiuserEnabled && session.sessionExpired && session.user === null) { + void navigate({ to: '/login' }); + } + }, [navigate, session.multiuserEnabled, session.sessionExpired, session.user]); + + return null; +}; diff --git a/invokeai/frontend/webv2/src/workbench/auth/components/SetupScreen.tsx b/invokeai/frontend/webv2/src/workbench/auth/components/SetupScreen.tsx new file mode 100644 index 00000000000..3e5a32cbd70 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/auth/components/SetupScreen.tsx @@ -0,0 +1,115 @@ +import { chakra, Input, Stack } from '@chakra-ui/react'; +import { useNavigate } from '@tanstack/react-router'; +import { createSetupSchema, PASSWORD_RULES_HINT, type SetupFormValues } from '@workbench/auth/schemas'; +import { completeAdminSetup, useAuthSession } from '@workbench/auth/session'; +import { getApiErrorMessage } from '@workbench/backend/http'; +import { Button, Field } from '@workbench/components/ui'; +import { useZodForm } from '@workbench/models/useZodForm'; +import { useCallback, useMemo, type ChangeEvent, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { AuthFormAlert, AuthScreen } from './AuthScreen'; +import { PasswordInput, PasswordStrengthMeter } from './PasswordInput'; + +const INITIAL_VALUES: SetupFormValues = { confirmPassword: '', displayName: '', email: '', password: '' }; + +/** + * First-run screen for multi-user mode: creates the administrator account and + * signs straight into it. + */ +export const SetupScreen = () => { + const { t } = useTranslation(); + const session = useAuthSession(); + const navigate = useNavigate(); + const schema = useMemo(() => createSetupSchema(session.strictPasswordChecking), [session.strictPasswordChecking]); + const form = useZodForm(schema, INITIAL_VALUES); + + const submit = useCallback( + () => + form.handleSubmit(async (values) => { + try { + await completeAdminSetup(values.email, values.displayName.trim() || null, values.password); + } catch (error) { + throw new Error(getApiErrorMessage(error, t('auth.couldNotCreateAdmin'))); + } + + await navigate({ to: '/' }); + }), + [form, navigate, t] + ); + const handleSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + void submit(); + }, + [submit] + ); + const handleEmailChange = useCallback( + (event: ChangeEvent) => form.setValue('email', event.target.value), + [form] + ); + const handleDisplayNameChange = useCallback( + (event: ChangeEvent) => form.setValue('displayName', event.target.value), + [form] + ); + const handlePasswordChange = useCallback( + (event: ChangeEvent) => form.setValue('password', event.target.value), + [form] + ); + const handleConfirmPasswordChange = useCallback( + (event: ChangeEvent) => form.setValue('confirmPassword', event.target.value), + [form] + ); + + return ( + + + {form.formError ? : null} + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/auth/schemas.test.ts b/invokeai/frontend/webv2/src/workbench/auth/schemas.test.ts new file mode 100644 index 00000000000..59c5a1c87c1 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/auth/schemas.test.ts @@ -0,0 +1,145 @@ +import { ApiError, getApiErrorMessage } from '@workbench/backend/http'; +import { describe, expect, it } from 'vitest'; + +import { + createProfileSchema, + createSetupSchema, + createUserFormSchema, + getPasswordStrength, + loginSchema, +} from './schemas'; + +describe('password strength', () => { + it('mirrors the backend rules', () => { + expect(getPasswordStrength('short')).toBe('weak'); + expect(getPasswordStrength('alllowercase')).toBe('moderate'); + expect(getPasswordStrength('NoDigitsHere')).toBe('moderate'); + expect(getPasswordStrength('Str0ngEnough')).toBe('strong'); + }); +}); + +describe('login schema', () => { + it('requires a valid email and a password', () => { + expect(loginSchema.safeParse({ email: 'not-an-email', password: 'x', rememberMe: false }).success).toBe(false); + expect(loginSchema.safeParse({ email: 'a@b.com', password: '', rememberMe: false }).success).toBe(false); + expect(loginSchema.safeParse({ email: 'a@b.com', password: 'pw', rememberMe: true }).success).toBe(true); + }); + + it('accepts special-use domains, matching the backend', () => { + expect(loginSchema.safeParse({ email: 'admin@localhost', password: 'pw', rememberMe: false }).success).toBe(true); + expect(loginSchema.safeParse({ email: 'dev@invoke.local', password: 'pw', rememberMe: false }).success).toBe(true); + }); +}); + +describe('setup schema', () => { + it('enforces strength only in strict mode', () => { + const base = { confirmPassword: 'simple', displayName: '', email: 'a@b.com', password: 'simple' }; + + expect(createSetupSchema(false).safeParse(base).success).toBe(true); + expect(createSetupSchema(true).safeParse(base).success).toBe(false); + expect( + createSetupSchema(true).safeParse({ ...base, confirmPassword: 'Str0ngEnough', password: 'Str0ngEnough' }).success + ).toBe(true); + }); + + it('rejects mismatched confirmation', () => { + const result = createSetupSchema(false).safeParse({ + confirmPassword: 'other', + displayName: '', + email: 'a@b.com', + password: 'simple', + }); + + expect(result.success).toBe(false); + }); +}); + +describe('profile schema', () => { + it('treats an empty new password as no change', () => { + const result = createProfileSchema(true).safeParse({ + confirmPassword: '', + currentPassword: '', + displayName: 'Me', + newPassword: '', + }); + + expect(result.success).toBe(true); + }); + + it('requires the current password and confirmation when changing', () => { + const schema = createProfileSchema(false); + + expect( + schema.safeParse({ confirmPassword: 'newpw', currentPassword: '', displayName: '', newPassword: 'newpw' }).success + ).toBe(false); + expect( + schema.safeParse({ confirmPassword: 'nope', currentPassword: 'old', displayName: '', newPassword: 'newpw' }) + .success + ).toBe(false); + expect( + schema.safeParse({ confirmPassword: 'newpw', currentPassword: 'old', displayName: '', newPassword: 'newpw' }) + .success + ).toBe(true); + }); +}); + +describe('user form schema', () => { + it('requires email and password when creating', () => { + const schema = createUserFormSchema(true, true); + + expect(schema.safeParse({ displayName: '', email: '', isAdmin: false, password: 'Str0ngEnough' }).success).toBe( + false + ); + expect(schema.safeParse({ displayName: '', email: 'a@b.com', isAdmin: false, password: 'weak' }).success).toBe( + false + ); + expect( + schema.safeParse({ displayName: '', email: 'a@b.com', isAdmin: true, password: 'Str0ngEnough' }).success + ).toBe(true); + }); + + it('allows an empty password when editing', () => { + const schema = createUserFormSchema(true, false); + + expect(schema.safeParse({ displayName: 'New Name', email: '', isAdmin: false, password: '' }).success).toBe(true); + expect(schema.safeParse({ displayName: '', email: '', isAdmin: false, password: 'weak' }).success).toBe(false); + }); +}); + +describe('getApiErrorMessage', () => { + it('unwraps FastAPI detail strings', () => { + expect(getApiErrorMessage(new ApiError('{"detail":"Incorrect email or password"}', 401), 'fallback')).toBe( + 'Incorrect email or password' + ); + }); + + it('unwraps the first validation issue', () => { + const body = JSON.stringify({ detail: [{ loc: ['body', 'email'], msg: 'value is not a valid email address' }] }); + + expect(getApiErrorMessage(new ApiError(body, 422), 'fallback')).toBe('email: value is not a valid email address'); + }); + + it('formats multiple-of validation issues with the field and received value', () => { + const body = JSON.stringify({ + detail: [ + { + ctx: { multiple_of: 16 }, + input: 888, + loc: ['body', 'batch', 'graph', 'nodes', 'denoise_latents', 'flux2_denoise', 'height'], + msg: 'Input should be a multiple of 16', + type: 'multiple_of', + }, + ], + }); + + expect(getApiErrorMessage(new ApiError(body, 422), 'fallback')).toBe( + 'height must be a multiple of 16 (received 888).' + ); + }); + + it('falls back for non-JSON bodies and unknown errors', () => { + expect(getApiErrorMessage(new ApiError('', 500), 'fallback')).toBe('fallback'); + expect(getApiErrorMessage(new ApiError('plain text', 500), 'fallback')).toBe('plain text'); + expect(getApiErrorMessage('nope', 'fallback')).toBe('fallback'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/auth/schemas.ts b/invokeai/frontend/webv2/src/workbench/auth/schemas.ts new file mode 100644 index 00000000000..d1ee8bc4ede --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/auth/schemas.ts @@ -0,0 +1,107 @@ +import { z } from 'zod'; + +/** + * Form schemas for the auth surfaces. Password rules mirror the backend's + * `validate_password_strength`: 8+ characters with an uppercase letter, a + * lowercase letter, and a digit. When strict checking is off the backend + * accepts any non-empty password, so the schemas relax to match. + */ + +export type PasswordStrength = 'weak' | 'moderate' | 'strong'; + +export const getPasswordStrength = (password: string): PasswordStrength => { + if (password.length < 8) { + return 'weak'; + } + + const hasUpper = /[A-Z]/.test(password); + const hasLower = /[a-z]/.test(password); + const hasDigit = /\d/.test(password); + + return hasUpper && hasLower && hasDigit ? 'strong' : 'moderate'; +}; + +export const PASSWORD_RULES_HINT = 'At least 8 characters, with an uppercase letter, a lowercase letter, and a digit.'; + +/** + * Deliberately lenient: the backend accepts special-use domains (`@localhost`, + * `.local`) for development setups, so the form only catches obvious mistakes + * and leaves real validation to the server. + */ +const emailSchema = z + .string() + .trim() + .refine((value) => { + const atIndex = value.indexOf('@'); + + return atIndex > 0 && atIndex < value.length - 1; + }, 'Enter a valid email address.'); + +const createPasswordSchema = (strict: boolean) => + strict + ? z + .string() + .min(8, 'Password must be at least 8 characters long.') + .refine((value) => getPasswordStrength(value) === 'strong', PASSWORD_RULES_HINT) + : z.string().min(1, 'Enter a password.'); + +/** Optional-password variant: empty string means "leave unchanged". */ +const createOptionalPasswordSchema = (strict: boolean) => + z.string().refine((value) => value === '' || !strict || getPasswordStrength(value) === 'strong', PASSWORD_RULES_HINT); + +export const loginSchema = z.object({ + email: emailSchema, + password: z.string().min(1, 'Enter your password.'), + rememberMe: z.boolean(), +}); + +export type LoginFormValues = z.infer; + +export const createSetupSchema = (strict: boolean) => + z + .object({ + confirmPassword: z.string(), + displayName: z.string(), + email: emailSchema, + password: createPasswordSchema(strict), + }) + .refine((values) => values.password === values.confirmPassword, { + message: 'Passwords do not match.', + path: ['confirmPassword'], + }); + +export type SetupFormValues = z.infer>; + +export const createProfileSchema = (strict: boolean) => + z + .object({ + confirmPassword: z.string(), + currentPassword: z.string(), + displayName: z.string(), + newPassword: createOptionalPasswordSchema(strict), + }) + .refine((values) => values.newPassword === '' || values.currentPassword !== '', { + message: 'Enter your current password to set a new one.', + path: ['currentPassword'], + }) + .refine((values) => values.newPassword === '' || values.newPassword === values.confirmPassword, { + message: 'Passwords do not match.', + path: ['confirmPassword'], + }); + +export type ProfileFormValues = z.infer>; + +/** + * One shape serves both admin user-form modes. Creating requires email and + * password; editing ignores the email field and treats an empty password as + * "leave unchanged". + */ +export const createUserFormSchema = (strict: boolean, requireCredentials: boolean) => + z.object({ + displayName: z.string(), + email: requireCredentials ? emailSchema : z.string(), + isAdmin: z.boolean(), + password: requireCredentials ? createPasswordSchema(strict) : createOptionalPasswordSchema(strict), + }); + +export type UserFormValues = z.infer>; diff --git a/invokeai/frontend/webv2/src/workbench/auth/session.ts b/invokeai/frontend/webv2/src/workbench/auth/session.ts new file mode 100644 index 00000000000..5a6c66eb495 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/auth/session.ts @@ -0,0 +1,171 @@ +import { ApiError, clearAuthToken, getAuthToken, setAuthToken, setUnauthorizedHandler } from '@workbench/backend/http'; +import { socketHub } from '@workbench/backend/socketHub'; +import { createExternalStore } from '@workbench/externalStore'; + +import { getAuthStatus, getCurrentUser, login, logout, setupAdmin, type AuthStatus, type UserDTO } from './api'; + +/** + * Session-lived auth state shared by the router guards and shell chrome. When + * multi-user is disabled on the backend the snapshot stays at its defaults + * (`multiuserEnabled: false`, `user: null`) and the entire auth surface — login + * screen, user menu, expiry handling — stays dormant. + */ +export interface AuthSession { + /** 'unknown' until the first `/auth/status` round-trip resolves. */ + phase: 'unknown' | 'ready'; + multiuserEnabled: boolean; + setupRequired: boolean; + strictPasswordChecking: boolean; + user: UserDTO | null; + /** Set when a stored token is rejected mid-session; shown on the login screen. */ + sessionExpired: boolean; +} + +const store = createExternalStore({ + multiuserEnabled: false, + phase: 'unknown', + sessionExpired: false, + setupRequired: false, + strictPasswordChecking: false, + user: null, +}); + +export const useAuthSession = (): AuthSession => store.useSnapshot(); + +/** + * Imperative read for non-reactive callers (e.g. the widget registry). Safe + * inside the workbench: the route guard resolves the session before mounting, + * and a user change remounts the whole workbench route. + */ +export const getAuthSession = (): AuthSession => store.getSnapshot(); + +/** + * Sticky across sign-out: a debounced autosave can still fire during the + * logout transition, and it must land in the bucket of the user whose data + * was loaded — never the shared single-user bucket. + */ +let activeUserScope = ''; + +const setActiveUserScope = (user: UserDTO | null): void => { + if (user !== null) { + activeUserScope = `:user:${user.user_id}`; + } +}; + +/** + * Suffix for localStorage keys holding user-owned state (projects, account + * preferences, personal API keys — see the spec's State Ownership section). + * Empty in single-user mode, so existing keys keep working unchanged. + */ +export const getUserStorageScope = (): string => (store.getSnapshot().multiuserEnabled ? activeUserScope : ''); + +const fetchAuthStatus = async (): Promise => { + try { + return await getAuthStatus(); + } catch { + // Backend unreachable: let the shell mount in single-user shape. The + // connection banner reports the outage, and a 401 once the backend returns + // in multi-user mode routes through the session-expiry path to login. + return { admin_email: null, multiuser_enabled: false, setup_required: false, strict_password_checking: false }; + } +}; + +const resolveSession = async (): Promise => { + const status = await fetchAuthStatus(); + let user: UserDTO | null = null; + + if (status.multiuser_enabled && !status.setup_required && getAuthToken()) { + try { + user = await getCurrentUser(); + } catch (error) { + if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { + clearAuthToken(); + } + // Any other failure leaves `user` null; the route guard lands on login. + } + } + + setActiveUserScope(user); + store.patchSnapshot({ + multiuserEnabled: status.multiuser_enabled, + phase: 'ready', + setupRequired: status.setup_required, + strictPasswordChecking: status.strict_password_checking, + user, + }); + + return store.getSnapshot(); +}; + +let pendingResolve: Promise | null = null; + +/** Resolve the session exactly once per app load; later calls reuse the snapshot. */ +export const ensureAuthSession = (): Promise => { + const current = store.getSnapshot(); + + if (current.phase === 'ready') { + return Promise.resolve(current); + } + + pendingResolve ??= resolveSession().finally(() => { + pendingResolve = null; + }); + + return pendingResolve; +}; + +export const loginWithCredentials = async (email: string, password: string, rememberMe: boolean): Promise => { + // A stale token must not ride along on the login request itself. + clearAuthToken(); + + const result = await login({ email, password, remember_me: rememberMe }); + + setAuthToken(result.token); + setActiveUserScope(result.user); + store.patchSnapshot({ sessionExpired: false, user: result.user }); +}; + +export const logoutSession = async (): Promise => { + try { + await logout(); + } catch { + // Tokens are stateless on the backend; local sign-out always wins. + } + + clearAuthToken(); + // Tear down the shared socket so it does not linger authenticated as the + // previous user; the next authenticated mount reconnects with a fresh token. + socketHub.disconnect(); + store.patchSnapshot({ sessionExpired: false, user: null }); +}; + +/** Create the initial admin account, then sign straight in with it. */ +export const completeAdminSetup = async ( + email: string, + displayName: string | null, + password: string +): Promise => { + await setupAdmin({ display_name: displayName, email, password }); + store.patchSnapshot({ setupRequired: false }); + await loginWithCredentials(email, password, false); +}; + +/** Reflect a profile edit (display name, password) into the session snapshot. */ +export const setSessionUser = (user: UserDTO): void => { + store.patchSnapshot({ user }); +}; + +// A 401 on any authenticated request means the stored token expired or was +// revoked. Only react while an authenticated multi-user session is live, so +// failed login attempts and single-user mode never trip it. +setUnauthorizedHandler(() => { + const session = store.getSnapshot(); + + if (!session.multiuserEnabled || session.user === null) { + return; + } + + clearAuthToken(); + socketHub.disconnect(); + store.patchSnapshot({ sessionExpired: true, user: null }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/backend/SocketHubRuntime.tsx b/invokeai/frontend/webv2/src/workbench/backend/SocketHubRuntime.tsx new file mode 100644 index 00000000000..9c8a4b9c91a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/SocketHubRuntime.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +import { socketHub } from './socketHub'; + +/** + * Renders nothing. Mounted once above both the Launchpad and the editor: opens + * the single backend socket for the authenticated session. Feature-specific + * runtimes attach listeners where needed so this base runtime stays lightweight. + * + * It intentionally does NOT disconnect on unmount — that keeps it StrictMode + * safe and lets the socket persist across Launchpad↔editor navigation. The + * socket is torn down explicitly on logout/expiry (see `auth/session.ts`). + */ +export const SocketHubRuntime = () => { + useEffect(() => { + socketHub.connect(); + }, []); + + return null; +}; diff --git a/invokeai/frontend/webv2/src/workbench/backend/connectionStore.ts b/invokeai/frontend/webv2/src/workbench/backend/connectionStore.ts new file mode 100644 index 00000000000..d2e87c5beb4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/connectionStore.ts @@ -0,0 +1,29 @@ +import type { BackendConnectionStatus } from '@workbench/types'; + +import { createExternalStore } from '@workbench/externalStore'; + +/** + * Provider-free connection status for the shared backend socket. The socket hub + * is the sole writer; surfaces that mount no workbench providers (the Launchpad) + * read it directly, and the editor mirrors it into workbench state via a bridge + * in `WorkbenchRuntime`. Lives outside the reducer so the connection signal is + * available everywhere the socket is, not just inside the editor. + */ +export interface ConnectionSnapshot { + status: BackendConnectionStatus; + error?: string; +} + +const store = createExternalStore({ status: 'connecting' }); + +export const setConnectionStatus = (status: BackendConnectionStatus, error?: string): void => { + store.setSnapshot({ error, status }); +}; + +export const getConnectionStatus = (): ConnectionSnapshot => store.getSnapshot(); + +export const useConnectionStatusSelector = store.useSelector; + +export const useConnectionStatus = (): ConnectionSnapshot => store.useSnapshot(); + +export const subscribeConnection = (listener: () => void): (() => void) => store.subscribe(listener); diff --git a/invokeai/frontend/webv2/src/workbench/backend/events.ts b/invokeai/frontend/webv2/src/workbench/backend/events.ts new file mode 100644 index 00000000000..48f709a61f6 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/events.ts @@ -0,0 +1,158 @@ +/** + * Typed contracts for the backend Socket.IO events webv2 consumes. Payload + * shapes mirror the pydantic models in + * `invokeai/app/services/events/events_common.py` (serialized as snake_case). + */ + +/** Backend queue item lifecycle status. Note the backend spells it `canceled`. */ +export type BackendQueueItemStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled'; + +export type TerminalBackendQueueItemStatus = Extract; + +export interface QueueItemEventBase { + queue_id: string; + item_id: number; + batch_id: string; + origin: string | null; + destination: string | null; +} + +export interface QueueItemStatusChangedEvent extends QueueItemEventBase { + status: BackendQueueItemStatus; + error_type: string | null; + error_message: string | null; + error_traceback: string | null; + created_at: string; + updated_at: string; + started_at: string | null; + completed_at: string | null; + session_id: string; +} + +export interface BatchEnqueuedEvent { + queue_id: string; + batch_id: string; + enqueued: number; + requested: number; + priority: number; + origin: string | null; + user_id: string; +} + +export interface QueueClearedEvent { + queue_id: string; +} + +export interface QueueItemsRetriedEvent { + queue_id: string; + retried_item_ids: number[]; +} + +/** Shared shape of per-invocation lifecycle events (`InvocationEventBase` on the backend). */ +export interface InvocationEventBase extends QueueItemEventBase { + session_id: string; + /** The id of the executing invocation's source node — the editor's node id. */ + invocation_source_id: string; +} + +export interface InvocationStartedEvent extends InvocationEventBase {} + +export interface InvocationProgressEvent extends InvocationEventBase { + message: string; + /** 0..1, or null for indeterminate progress. */ + percentage: number | null; + /** Intermittent denoising preview, when the invocation produces one. */ + image?: { width: number; height: number; dataURL: string } | null; +} + +export interface InvocationCompleteEvent extends InvocationEventBase { + /** The invocation's output, discriminated by its `type` (e.g. `image_output`). */ + result: { type: string } & Record; +} + +export interface InvocationErrorEvent extends InvocationEventBase { + error_type: string; + error_message: string; +} + +export interface BackendSocketEvents { + queue_item_status_changed: QueueItemStatusChangedEvent; + batch_enqueued: BatchEnqueuedEvent; + queue_cleared: QueueClearedEvent; + queue_items_retried: QueueItemsRetriedEvent; + invocation_started: InvocationStartedEvent; + invocation_progress: InvocationProgressEvent; + invocation_complete: InvocationCompleteEvent; + invocation_error: InvocationErrorEvent; +} + +export const isTerminalBackendStatus = (status: BackendQueueItemStatus): status is TerminalBackendQueueItemStatus => + status === 'completed' || status === 'failed' || status === 'canceled'; + +/** + * Queue items enqueued by webv2 carry the local queue item id in their origin + * so that submissions survive a reload: on startup the backend queue is listed + * and items are re-adopted by decoding their origin. + */ +const QUEUE_ITEM_ORIGIN_PREFIX = 'webv2:'; +const PROJECT_QUEUE_ITEM_ORIGIN_PREFIX = 'webv2:p:'; + +/** + * The origin prefix for utility-queue items — small graphs (filter previews, + * SAM, …) enqueued OUTSIDE any project's queue and awaited directly via + * `socketHub.on` (see `canvas-engine/backend/utilityQueue.ts`). + * + * The whole point of a distinct prefix is result isolation (plan Risk 4): a + * utility item must never be mistaken for a project queue item and routed into + * staging or the gallery. `parseQueueItemOrigin` therefore returns `null` for it + * — so `queueCoordinator.reconcile` and `isQueueServerItemInProject` never map a + * utility backend item to a local project item, and `routeQueueItemResults` + * (only ever invoked for coordinator-tracked project runs, which utility items + * are never registered as) never sees it. The `util:` segment sits under the + * shared `webv2:` namespace but is checked BEFORE the generic branch below, so + * it is not misparsed as a bare (non-project) local queue item id. + */ +const UTILITY_QUEUE_ITEM_ORIGIN_PREFIX = 'webv2:util:'; + +export const buildProjectQueueItemOriginPrefix = (projectId: string): string => + `${PROJECT_QUEUE_ITEM_ORIGIN_PREFIX}${projectId}:q:`; + +export const buildQueueItemOrigin = (localQueueItemId: string, projectId?: string): string => + projectId + ? `${buildProjectQueueItemOriginPrefix(projectId)}${localQueueItemId}` + : `${QUEUE_ITEM_ORIGIN_PREFIX}${localQueueItemId}`; + +/** Builds the isolated origin for a utility-queue item (`webv2:util:`). */ +export const buildUtilityQueueItemOrigin = (utilityId: string): string => + `${UTILITY_QUEUE_ITEM_ORIGIN_PREFIX}${utilityId}`; + +/** True when `origin` belongs to the utility queue (never a project/local queue item). */ +export const isUtilityQueueItemOrigin = (origin: string | null | undefined): boolean => + origin?.startsWith(UTILITY_QUEUE_ITEM_ORIGIN_PREFIX) ?? false; + +export const parseQueueItemOrigin = (origin: string | null | undefined): string | null => { + // Utility items are intentionally invisible to project routing (Risk 4): they + // resolve to no local queue item, so nothing adopts them or routes their + // results. Checked first because `webv2:util:` also matches the generic + // `webv2:` branch below. + if (isUtilityQueueItemOrigin(origin)) { + return null; + } + + return origin?.startsWith(PROJECT_QUEUE_ITEM_ORIGIN_PREFIX) + ? origin.slice(origin.lastIndexOf(':q:') + 3) + : origin?.startsWith(QUEUE_ITEM_ORIGIN_PREFIX) + ? origin.slice(QUEUE_ITEM_ORIGIN_PREFIX.length) + : null; +}; + +export const parseQueueItemOriginProjectId = (origin: string | null | undefined): string | null => { + if (!origin?.startsWith(PROJECT_QUEUE_ITEM_ORIGIN_PREFIX)) { + return null; + } + + const rest = origin.slice(PROJECT_QUEUE_ITEM_ORIGIN_PREFIX.length); + const separatorIndex = rest.indexOf(':q:'); + + return separatorIndex === -1 ? null : rest.slice(0, separatorIndex); +}; diff --git a/invokeai/frontend/webv2/src/workbench/backend/http.ts b/invokeai/frontend/webv2/src/workbench/backend/http.ts new file mode 100644 index 00000000000..399c644b561 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/http.ts @@ -0,0 +1,196 @@ +/** + * Shared HTTP client for the InvokeAI backend. Every REST call goes through + * `apiFetch` so authentication matches the WebSocket connection: both read the + * same `auth_token` and send it as a bearer token. The token is read per + * request, so a login that lands mid-session applies without a reload. + */ + +const API_BASE_URL = import.meta.env.VITE_INVOKEAI_API_BASE_URL ?? ''; +const AUTH_TOKEN_STORAGE_KEY = 'auth_token'; + +export const getAuthToken = (): string | null => { + try { + return window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY); + } catch { + return null; + } +}; + +export const setAuthToken = (token: string): void => { + try { + window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token); + } catch { + // Storage unavailable (private mode, quota): the session lasts until reload. + } +}; + +export const clearAuthToken = (): void => { + try { + window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY); + } catch { + // Nothing to clear if storage is unavailable. + } +}; + +/** + * Called when an authenticated request comes back 401 — the stored token is no + * longer valid. The auth session store registers itself here so the HTTP layer + * stays unaware of session semantics. + */ +let unauthorizedHandler: (() => void) | null = null; + +export const setUnauthorizedHandler = (handler: (() => void) | null): void => { + unauthorizedHandler = handler; +}; + +export const getBackendSocketUrl = (): string => { + if (!API_BASE_URL.trim()) { + return window.location.origin; + } + + return new URL(API_BASE_URL, window.location.origin).origin; +}; + +export const buildApiUrl = (path: string): string => `${API_BASE_URL}${path}`; + +/** Resolve a backend-relative resource URL (e.g. image URLs in DTOs) against the API host. */ +export const absolutizeApiUrl = (url: string): string => { + if (!API_BASE_URL || url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + + return new URL(url, API_BASE_URL).toString(); +}; + +export class ApiError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + +const humanizeFieldName = (value: string): string => value.replaceAll('_', ' '); + +const getLastString = (values: unknown[]): string | null => { + for (let index = values.length - 1; index >= 0; index -= 1) { + const value = values[index]; + + if (typeof value === 'string') { + return value; + } + } + + return null; +}; + +const getValidationIssueMessage = (issue: unknown): string | null => { + if (!issue || typeof issue !== 'object') { + return null; + } + + const record = issue as { ctx?: unknown; input?: unknown; loc?: unknown; msg?: unknown; type?: unknown }; + const loc = Array.isArray(record.loc) ? record.loc : []; + const lastLoc = getLastString(loc); + const field = lastLoc ? humanizeFieldName(lastLoc) : null; + + if (record.type === 'multiple_of') { + const ctx = record.ctx && typeof record.ctx === 'object' ? (record.ctx as { multiple_of?: unknown }) : null; + const multipleOf = ctx?.multiple_of; + + if (field && typeof multipleOf === 'number') { + const received = + typeof record.input === 'number' || typeof record.input === 'string' ? ` (received ${record.input})` : ''; + + return `${field} must be a multiple of ${multipleOf}${received}.`; + } + } + + if (typeof record.msg === 'string' && record.msg) { + return field ? `${field}: ${record.msg}` : record.msg; + } + + return null; +}; + +export const assertOk = async (response: Response): Promise => { + if (response.ok) { + return response; + } + + const text = await response.text(); + throw new ApiError(text || `${response.status} ${response.statusText}`, response.status); +}; + +/** Authenticated fetch that leaves status handling to the caller. */ +export const apiFetchRaw = (path: string, init?: RequestInit): Promise => { + const token = getAuthToken(); + const headers = new Headers(init?.headers); + + if (token && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`); + } + + return fetch(buildApiUrl(path), { ...init, headers }); +}; + +export const apiFetch = async (path: string, init?: RequestInit): Promise => { + const hadToken = getAuthToken() !== null; + const response = await apiFetchRaw(path, init); + + if (response.status === 401 && hadToken) { + unauthorizedHandler?.(); + } + + return assertOk(response); +}; + +/** + * Backend errors arrive as FastAPI JSON (`{"detail": "..."}`); `ApiError` + * carries the raw body. This unwraps `detail` into a human-readable message. + */ +export const getApiErrorMessage = (error: unknown, fallback: string): string => { + if (error instanceof ApiError) { + try { + const parsed = JSON.parse(error.message) as { detail?: unknown }; + + if (typeof parsed.detail === 'string' && parsed.detail) { + return parsed.detail; + } + + // Validation errors come as a list of issues; surface the first one. + if (Array.isArray(parsed.detail)) { + const message = getValidationIssueMessage(parsed.detail[0]); + + if (message) { + return message; + } + } + } catch { + // Not JSON — fall through to the raw message. + } + + return error.message || fallback; + } + + return error instanceof Error && error.message ? error.message : fallback; +}; + +export const apiFetchJson = async (path: string, init?: RequestInit): Promise => { + const headers = new Headers(init?.headers); + + if (init?.body !== undefined && !(init.body instanceof FormData) && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const response = await apiFetch(path, { ...init, headers }); + + return (await response.json()) as T; +}; + +export const sleep = (ms: number): Promise => + new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); diff --git a/invokeai/frontend/webv2/src/workbench/backend/itemProgressStore.test.ts b/invokeai/frontend/webv2/src/workbench/backend/itemProgressStore.test.ts new file mode 100644 index 00000000000..91e23d338db --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/itemProgressStore.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { itemProgressStore } from './itemProgressStore'; + +describe('itemProgressStore', () => { + beforeEach(() => { + itemProgressStore.clearAll(); + }); + + it('preserves the previous live preview image when a progress update has no image payload', () => { + itemProgressStore.set(42, { + image: { dataUrl: 'data:image/png;base64,step-1', height: 64, width: 64 }, + message: 'Denoising 1/20', + percentage: 0.05, + }); + + itemProgressStore.set(42, { + message: 'Denoising 2/20', + percentage: 0.1, + }); + + expect(itemProgressStore.get(42)).toEqual({ + image: { dataUrl: 'data:image/png;base64,step-1', height: 64, width: 64 }, + message: 'Denoising 2/20', + percentage: 0.1, + }); + }); + + it('clears the live preview image when a progress update explicitly sets image to null', () => { + itemProgressStore.set(42, { + image: { dataUrl: 'data:image/png;base64,step-1', height: 64, width: 64 }, + message: 'Denoising 1/20', + percentage: 0.05, + }); + + itemProgressStore.set(42, { + image: null, + message: '', + percentage: null, + }); + + expect(itemProgressStore.get(42)).toEqual({ + image: null, + message: '', + percentage: null, + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/backend/itemProgressStore.ts b/invokeai/frontend/webv2/src/workbench/backend/itemProgressStore.ts new file mode 100644 index 00000000000..dee301fff10 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/itemProgressStore.ts @@ -0,0 +1,41 @@ +import { createKeyedTransientStore } from '@workbench/externalStore'; +import { mergeDenoiseProgressImage, type DenoiseProgressImage } from '@workbench/images/streamingImageSource'; + +/** + * Live progress for in-flight queue items keyed by the backend `item_id`. The + * sibling `progressStore` keys by the *local* submission id, which only exists + * for items this client enqueued; the Queue widget shows the whole server queue, + * so its NOW & NEXT card needs progress addressable by the backend id carried on + * the socket's `invocation_started`/`invocation_progress` events. + */ + +export interface ItemProgress { + message: string; + /** 0..1, or null while indeterminate. */ + percentage: number | null; + image?: DenoiseProgressImage | null; +} + +const progressByItemId = createKeyedTransientStore(); + +export const itemProgressStore = { + get(itemId: number): ItemProgress | null { + return progressByItemId.get(itemId) ?? null; + }, + set(itemId: number, progress: ItemProgress): void { + const current = progressByItemId.get(itemId); + const image = mergeDenoiseProgressImage(current?.image, progress.image); + const next = image === undefined ? progress : { ...progress, image }; + + progressByItemId.set(itemId, next); + }, + clear(itemId: number): void { + progressByItemId.delete(itemId); + }, + clearAll(): void { + progressByItemId.clear(); + }, +}; + +export const useItemProgress = (itemId: number | null | undefined): ItemProgress | null => + progressByItemId.useValue(itemId ?? -1) ?? null; diff --git a/invokeai/frontend/webv2/src/workbench/backend/modelLoadStore.ts b/invokeai/frontend/webv2/src/workbench/backend/modelLoadStore.ts new file mode 100644 index 00000000000..d4835cd3314 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/modelLoadStore.ts @@ -0,0 +1,38 @@ +import { createExternalStore } from '@workbench/externalStore'; + +export interface ModelLoadInfo { + label: string; +} + +const store = createExternalStore<{ activeLoads: ModelLoadInfo[] }>({ activeLoads: [] }); + +const getModelLoadLabel = (payload: unknown): string => { + const record = payload && typeof payload === 'object' ? (payload as Record) : {}; + const config = record.config && typeof record.config === 'object' ? (record.config as Record) : {}; + const name = typeof config.name === 'string' && config.name.trim() ? config.name.trim() : 'model'; + const extras = [config.base, config.type, record.submodel_type].filter( + (value): value is string => typeof value === 'string' && value.trim().length > 0 + ); + + return extras.length ? `${name} (${extras.join(', ')})` : name; +}; + +export const modelLoadStore = { + started(payload: unknown): void { + store.patchSnapshot({ activeLoads: [...store.getSnapshot().activeLoads, { label: getModelLoadLabel(payload) }] }); + }, + completed(payload: unknown): void { + const label = getModelLoadLabel(payload); + const { activeLoads } = store.getSnapshot(); + const index = activeLoads.findIndex((load) => load.label === label); + + store.patchSnapshot({ + activeLoads: index >= 0 ? activeLoads.filter((_, loadIndex) => loadIndex !== index) : activeLoads.slice(1), + }); + }, + reset(): void { + store.patchSnapshot({ activeLoads: [] }); + }, +}; + +export const useModelLoads = (): ModelLoadInfo[] => store.useSelector((snapshot) => snapshot.activeLoads); diff --git a/invokeai/frontend/webv2/src/workbench/backend/nodeExecutionStore.ts b/invokeai/frontend/webv2/src/workbench/backend/nodeExecutionStore.ts new file mode 100644 index 00000000000..477f732f64b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/nodeExecutionStore.ts @@ -0,0 +1,100 @@ +import { createKeyedTransientStore } from '@workbench/externalStore'; + +import type { InvocationCompleteEvent, InvocationErrorEvent, InvocationStartedEvent } from './events'; + +import { buildApiUrl } from './http'; + +/** + * Ephemeral per-node execution state, keyed by the invocation's source node id + * (the workflow editor's node id). Like the queue-item progress store, this is + * high-frequency transient data that deliberately lives outside the workbench + * reducer; the editor's nodes subscribe per id and only re-render when their + * own node's state moves. + */ + +export type NodeExecutionStatus = 'running' | 'completed' | 'failed'; + +export interface NodeExecutionState { + status: NodeExecutionStatus; + /** 0..1, or null while indeterminate. Only meaningful while running. */ + progress: number | null; + progressMessage: string | null; + /** Thumbnail of the node's most recent image output, when it produced one. */ + outputImageUrl: string | null; + error: string | null; +} + +const stateByNodeId = createKeyedTransientStore(); + +/** Pull the produced image out of an invocation output, whatever the node type. */ +const getResultImageName = (result: InvocationCompleteEvent['result']): string | null => { + const image = (result as { image?: { image_name?: unknown } }).image; + + return typeof image?.image_name === 'string' ? image.image_name : null; +}; + +export const nodeExecutionStore = { + clearAll(): void { + stateByNodeId.clear(); + }, + completed(event: InvocationCompleteEvent): void { + const imageName = getResultImageName(event.result); + const previous = stateByNodeId.get(event.invocation_source_id); + + stateByNodeId.set(event.invocation_source_id, { + error: null, + outputImageUrl: imageName + ? buildApiUrl(`/api/v1/images/i/${encodeURIComponent(imageName)}/thumbnail`) + : (previous?.outputImageUrl ?? null), + progress: null, + progressMessage: null, + status: 'completed', + }); + }, + failed(event: InvocationErrorEvent): void { + const previous = stateByNodeId.get(event.invocation_source_id); + + stateByNodeId.set(event.invocation_source_id, { + error: event.error_message, + outputImageUrl: previous?.outputImageUrl ?? null, + progress: null, + progressMessage: null, + status: 'failed', + }); + }, + progress(nodeId: string, percentage: number | null, message: string): void { + const previous = stateByNodeId.get(nodeId); + + stateByNodeId.set(nodeId, { + error: null, + outputImageUrl: previous?.outputImageUrl ?? null, + progress: percentage, + progressMessage: message, + status: 'running', + }); + }, + /** A queue item reached a terminal state: nothing can still be running. */ + settleRunning(): void { + for (const [nodeId, state] of stateByNodeId.entries()) { + if (state.status === 'running') { + stateByNodeId.set(nodeId, { ...state, progress: null, progressMessage: null, status: 'completed' }); + } + } + }, + started(event: InvocationStartedEvent): void { + const previous = stateByNodeId.get(event.invocation_source_id); + + stateByNodeId.set(event.invocation_source_id, { + error: null, + outputImageUrl: previous?.outputImageUrl ?? null, + progress: null, + progressMessage: null, + status: 'running', + }); + }, +}; + +export type NodeExecutionSink = typeof nodeExecutionStore; + +export const useNodeExecutionState = (nodeId: string): NodeExecutionState | null => + stateByNodeId.useValue(nodeId) ?? null; diff --git a/invokeai/frontend/webv2/src/workbench/backend/progressImageStore.ts b/invokeai/frontend/webv2/src/workbench/backend/progressImageStore.ts new file mode 100644 index 00000000000..fd1ce3e21e3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/progressImageStore.ts @@ -0,0 +1,64 @@ +import type { DenoiseProgressImage } from '@workbench/images/streamingImageSource'; + +import { createExternalStore, createKeyedTransientStore } from '@workbench/externalStore'; + +/** + * The most recent denoising preview image from `invocation_progress` events, + * as a b64 data URL. Cleared when the run settles so consumers (the editor's + * Current Image node, progress surfaces) fall back to the last real output. + */ + +export type ProgressImageSnapshot = DenoiseProgressImage; + +export interface ProgressImageTarget { + queueItemId: string; + itemIndex: number; +} + +export type LatestProgressImageSnapshot = ProgressImageSnapshot & { target?: ProgressImageTarget }; + +const latestSnapshotStore = createExternalStore<{ latestSnapshot: LatestProgressImageSnapshot | null }>({ + latestSnapshot: null, +}); +const snapshotsByTarget = createKeyedTransientStore(); + +const getTargetKey = ({ itemIndex, queueItemId }: ProgressImageTarget): string => `${queueItemId}:${itemIndex}`; + +const isLatestTarget = (target: ProgressImageTarget): boolean => + latestSnapshotStore.getSnapshot().latestSnapshot?.target?.queueItemId === target.queueItemId && + latestSnapshotStore.getSnapshot().latestSnapshot?.target?.itemIndex === target.itemIndex; + +export const progressImageStore = { + clear(target?: ProgressImageTarget): void { + if (!target) { + latestSnapshotStore.patchSnapshot({ latestSnapshot: null }); + snapshotsByTarget.clear(); + + return; + } + + const targetKey = getTargetKey(target); + const didClearLatest = isLatestTarget(target); + + snapshotsByTarget.delete(targetKey); + + if (didClearLatest) { + latestSnapshotStore.patchSnapshot({ latestSnapshot: null }); + } + }, + set(image: ProgressImageSnapshot, target?: ProgressImageTarget): void { + latestSnapshotStore.patchSnapshot({ latestSnapshot: target ? { ...image, target } : image }); + + if (target) { + snapshotsByTarget.set(getTargetKey(target), image); + } + }, +}; + +export type ProgressImageSink = typeof progressImageStore; + +export const useProgressImage = (): LatestProgressImageSnapshot | null => + latestSnapshotStore.useSelector((snapshot) => snapshot.latestSnapshot); + +export const useQueueItemProgressImage = (queueItemId: string, itemIndex: number): ProgressImageSnapshot | null => + snapshotsByTarget.useValue(getTargetKey({ itemIndex, queueItemId })) ?? null; diff --git a/invokeai/frontend/webv2/src/workbench/backend/progressStore.ts b/invokeai/frontend/webv2/src/workbench/backend/progressStore.ts new file mode 100644 index 00000000000..b96ac078846 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/progressStore.ts @@ -0,0 +1,43 @@ +import { createKeyedTransientStore } from '@workbench/externalStore'; + +/** + * Ephemeral live-progress store for in-flight queue items, keyed by the local + * queue item id. Progress is high-frequency transient data, so it deliberately + * lives outside the workbench reducer: routing it through dispatch would + * re-render every consumer of workbench state and churn autosave on each step + * of every generation. Any widget can subscribe to a single item via + * `useQueueItemProgress` and only re-renders when that item's progress moves. + */ + +export interface QueueItemProgress { + /** 1-based image slot currently executing inside this local batch. */ + activeItemIndex?: number; + completedItemCount?: number; + message: string; + /** 0..1, or null while indeterminate. */ + percentage: number | null; + totalItemCount?: number; +} + +export interface QueueItemProgressSink { + clearAll?(): void; + set(queueItemId: string, progress: QueueItemProgress): void; + clear(queueItemId: string): void; +} + +const progressByQueueItemId = createKeyedTransientStore(); + +export const queueItemProgressStore: QueueItemProgressSink = { + clearAll() { + progressByQueueItemId.clear(); + }, + clear(queueItemId) { + progressByQueueItemId.delete(queueItemId); + }, + set(queueItemId, progress) { + progressByQueueItemId.set(queueItemId, progress); + }, +}; + +export const useQueueItemProgress = (queueItemId: string): QueueItemProgress | null => + progressByQueueItemId.useValue(queueItemId) ?? null; diff --git a/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.test.ts b/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.test.ts new file mode 100644 index 00000000000..1fa2bec550e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.test.ts @@ -0,0 +1,675 @@ +import type { EnqueueGenerateRequest, ImageDTO, QueueItemDTO } from '@workbench/generation/types'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { NodeExecutionSink } from './nodeExecutionStore'; +import type { QueueItemProgress, QueueItemProgressSink } from './progressStore'; + +import { buildQueueItemOrigin, buildUtilityQueueItemOrigin, type QueueItemStatusChangedEvent } from './events'; +import { ApiError } from './http'; +import { + createQueueCoordinator, + QueueItemCancelledError, + type QueueCoordinator, + type QueueCoordinatorApi, + type QueueCoordinatorCallbacks, +} from './queueCoordinator'; +import { createSocketHub, type BackendSocket } from './socketHub'; + +class FakeSocket implements BackendSocket { + readonly emitted: { event: string; payload: unknown }[] = []; + private readonly handlers = new Map void)[]>(); + + on(event: string, handler: (payload: never) => void): void { + this.handlers.set(event, [...(this.handlers.get(event) ?? []), handler]); + } + + off(event: string, handler: (payload: never) => void): void { + this.handlers.set( + event, + (this.handlers.get(event) ?? []).filter((existing) => existing !== handler) + ); + } + + emit(event: string, payload: unknown): void { + this.emitted.push({ event, payload }); + } + + connect(): void { + this.fire('connect', undefined); + } + + disconnect(): void { + this.fire('disconnect', 'io client disconnect'); + } + + fire(event: string, payload: unknown): void { + for (const handler of this.handlers.get(event) ?? []) { + (handler as (value: unknown) => void)(payload); + } + } +} + +const createStatusEvent = (overrides: Partial): QueueItemStatusChangedEvent => ({ + batch_id: 'batch-1', + completed_at: null, + created_at: '2026-06-10T00:00:00Z', + destination: 'gallery', + error_message: null, + error_traceback: null, + error_type: null, + item_id: 1, + origin: null, + queue_id: 'default', + session_id: 'session-1', + started_at: null, + status: 'completed', + updated_at: '2026-06-10T00:00:00Z', + ...overrides, +}); + +const createQueueItemDTO = (overrides: Partial): QueueItemDTO => ({ + item_id: 1, + session: { results: {} }, + status: 'in_progress', + ...overrides, +}); + +const createImage = (imageName: string, sourceQueueItemId: string): ImageDTO => ({ + height: 64, + imageName, + imageUrl: `https://example.test/${imageName}`, + isIntermediate: false, + queuedAt: '2026-06-10T00:00:00Z', + sourceQueueItemId, + thumbnailUrl: `https://example.test/${imageName}/thumb`, + width: 64, +}); + +const generateRequest: EnqueueGenerateRequest = { + batchCount: 1, + destination: 'gallery', + graph: { edges: [], id: 'graph-1', nodes: {} }, + negativePrompt: '', + negativePromptNodeId: 'negative_prompt', + positivePrompt: 'a fjord at dawn', + positivePromptNodeId: 'positive_prompt', + projectId: 'project-1', + seed: 1, + seedNodeId: 'seed', + shouldRandomizeSeed: false, + sourceQueueItemId: 'local-1', +}; + +interface Harness { + api: { [Key in keyof QueueCoordinatorApi]: ReturnType }; + callbacks: { [Key in keyof QueueCoordinatorCallbacks]: ReturnType }; + coordinator: QueueCoordinator; + hub: ReturnType; + nodeExecution: { [Key in keyof NodeExecutionSink]: ReturnType }; + progressImage: { clear: ReturnType; set: ReturnType }; + progressEntries: Map; + socket: FakeSocket; +} + +const createHarness = (options: { galleryRefreshCoalesceMs?: number } = {}): Harness => { + const socket = new FakeSocket(); + const progressEntries = new Map(); + const progress: QueueItemProgressSink = { + clear: (queueItemId) => { + progressEntries.delete(queueItemId); + }, + set: (queueItemId, value) => { + progressEntries.set(queueItemId, value); + }, + }; + const api = { + cancelQueueItems: vi.fn(() => Promise.resolve()), + cancelQueueItemsByBatchIds: vi.fn(() => Promise.resolve()), + enqueueGenerateGraph: vi.fn(() => Promise.resolve({ batchId: 'batch-1', enqueued: 1, itemIds: [1], requested: 1 })), + enqueueWorkflowGraph: vi.fn(() => Promise.resolve({ batchId: 'batch-1', enqueued: 1, itemIds: [1], requested: 1 })), + getQueueItem: vi.fn((itemId: number) => Promise.resolve(createQueueItemDTO({ item_id: itemId }))), + getQueueItemResultImages: vi.fn((itemId: number, sourceQueueItemId: string) => + Promise.resolve([createImage(`image-${itemId}.png`, sourceQueueItemId)]) + ), + listAllQueueItems: vi.fn((): Promise => Promise.resolve([])), + }; + const callbacks = { + onGalleryRefresh: vi.fn(), + }; + const nodeExecution = { + clearAll: vi.fn(), + completed: vi.fn(), + failed: vi.fn(), + progress: vi.fn(), + settleRunning: vi.fn(), + started: vi.fn(), + }; + const progressImage = { clear: vi.fn(), set: vi.fn() }; + const hub = createSocketHub({ createSocket: () => socket }); + + hub.connect(); + + const coordinator = createQueueCoordinator(callbacks, { + api, + galleryRefreshCoalesceMs: options.galleryRefreshCoalesceMs ?? 1, + hub, + nodeExecution, + progress, + progressImage, + }); + + return { api, callbacks, coordinator, hub, nodeExecution, progressImage, progressEntries, socket }; +}; + +describe('queueCoordinator', () => { + let harness: Harness; + + beforeEach(() => { + harness = createHarness(); + }); + + afterEach(() => { + harness.coordinator.dispose(); + harness.hub.disconnect(); + vi.useRealTimers(); + }); + + it('settles submitted runs from terminal socket events without polling', async () => { + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 2, + itemIds: [1, 2], + requested: 2, + }); + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 2 })); + + const images = await resultsPromise; + + expect(images.map((image) => image.imageName)).toEqual(['image-1.png', 'image-2.png']); + expect(harness.api.getQueueItem).not.toHaveBeenCalled(); + }); + + it('rejects with the backend error message when a run fails', async () => { + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire( + 'queue_item_status_changed', + createStatusEvent({ error_message: 'CUDA out of memory', item_id: 1, status: 'failed' }) + ); + + await expect(resultsPromise).rejects.toThrow('CUDA out of memory'); + }); + + it('rejects with QueueItemCancelledError when the backend cancels a run', async () => { + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1, status: 'canceled' })); + + await expect(resultsPromise).rejects.toBeInstanceOf(QueueItemCancelledError); + }); + + it('returns completed batch item results when sibling backend items are canceled', async () => { + harness.callbacks.onBackendItemCancelled = vi.fn(); + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 3, + itemIds: [1, 2, 3], + requested: 3, + }); + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1, status: 'canceled' })); + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 2 })); + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 3, status: 'canceled' })); + + const images = await resultsPromise; + + expect(images.map((image) => image.imageName)).toEqual(['image-2.png']); + expect(harness.api.getQueueItemResultImages).toHaveBeenCalledTimes(1); + expect(harness.api.getQueueItemResultImages).toHaveBeenCalledWith(2, 'local-1', '2026-06-10T00:00:00Z'); + expect(harness.callbacks.onBackendItemCancelled).toHaveBeenCalledWith('local-1', 1); + expect(harness.callbacks.onBackendItemCancelled).toHaveBeenCalledWith('local-1', 3); + }); + + it('forwards result image extraction options when waiting for results', async () => { + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z', { + resultNodeIds: ['canvas_output'], + }); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + + await resultsPromise; + + expect(harness.api.getQueueItemResultImages).toHaveBeenCalledWith(1, 'local-1', '2026-06-10T00:00:00Z', { + resultNodeIds: ['canvas_output'], + }); + }); + + it('rejects with QueueItemCancelledError when every backend item in a batch is canceled', async () => { + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 2, + itemIds: [1, 2], + requested: 2, + }); + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1, status: 'canceled' })); + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 2, status: 'canceled' })); + + await expect(resultsPromise).rejects.toBeInstanceOf(QueueItemCancelledError); + expect(harness.api.getQueueItemResultImages).not.toHaveBeenCalled(); + }); + + it('settles runs whose terminal event arrived before tracking began', async () => { + harness.coordinator.connect(); + + // The event for item 1 lands while enqueue_batch is still resolving. + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + + const images = await harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + expect(images.map((image) => image.imageName)).toEqual(['image-1.png']); + }); + + it('rejects runs when the backend queue accepts no items', async () => { + harness.api.enqueueGenerateGraph.mockResolvedValue({ batchId: 'batch-1', enqueued: 0, itemIds: [], requested: 1 }); + + await expect(harness.coordinator.submitGenerate('local-1', generateRequest)).rejects.toThrow( + 'The backend queue did not accept this generation.' + ); + }); + + it('rejects runs when the backend queue accepts only part of the batch', async () => { + harness.api.enqueueGenerateGraph.mockResolvedValue({ batchId: 'batch-1', enqueued: 1, itemIds: [1], requested: 2 }); + + await expect(harness.coordinator.submitGenerate('local-1', generateRequest)).rejects.toThrow( + 'The backend queue accepted 1 of 2 requested items.' + ); + }); + + it('coalesces gallery refreshes across a burst of completions', async () => { + vi.useFakeTimers(); + harness = createHarness({ galleryRefreshCoalesceMs: 400 }); + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 10, + itemIds: Array.from({ length: 10 }, (_, index) => index + 1), + requested: 10, + }); + harness.coordinator.connect(); + await harness.coordinator.submitGenerate('local-1', generateRequest); + await vi.advanceTimersByTimeAsync(500); // flush the on-connect refresh + harness.callbacks.onGalleryRefresh.mockClear(); + + for (let itemId = 1; itemId <= 10; itemId += 1) { + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: itemId })); + } + + await vi.advanceTimersByTimeAsync(500); + + expect(harness.callbacks.onGalleryRefresh).toHaveBeenCalledTimes(1); + }); + + it('does not refresh the gallery for failed or canceled items', async () => { + vi.useFakeTimers(); + harness = createHarness({ galleryRefreshCoalesceMs: 400 }); + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 2, + itemIds: [1, 2], + requested: 2, + }); + harness.coordinator.connect(); + await harness.coordinator.submitGenerate('local-1', generateRequest); + await vi.advanceTimersByTimeAsync(500); // flush the on-connect refresh + harness.callbacks.onGalleryRefresh.mockClear(); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1, status: 'failed' })); + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 2, status: 'canceled' })); + + await vi.advanceTimersByTimeAsync(500); + + expect(harness.callbacks.onGalleryRefresh).not.toHaveBeenCalled(); + }); + + it('routes progress events to the tracked item and clears them on completion', async () => { + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + + harness.socket.fire('invocation_progress', { + ...createStatusEvent({ item_id: 1 }), + message: 'Denoising', + percentage: 0.5, + }); + + expect(harness.progressEntries.get('local-1')).toEqual({ + activeItemIndex: 1, + completedItemCount: 0, + message: 'Denoising', + percentage: 0.5, + totalItemCount: 1, + }); + + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + await resultsPromise; + + expect(harness.progressEntries.has('local-1')).toBe(false); + expect(harness.progressImage.clear).toHaveBeenCalledWith({ itemIndex: 1, queueItemId: 'local-1' }); + }); + + it('keeps the completed progress image until backend item result routing finishes', async () => { + let finishRouting: () => void = () => undefined; + const routingPromise = new Promise((resolve) => { + finishRouting = resolve; + }); + + harness.callbacks.onBackendItemComplete = vi.fn(() => routingPromise); + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire('invocation_progress', { + ...createStatusEvent({ item_id: 1 }), + image: { dataURL: 'data:image/png;base64,final-denoise', height: 32, width: 64 }, + invocation_source_id: 'denoise', + message: 'Denoising', + percentage: 1, + }); + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + + await resultsPromise; + + expect(harness.callbacks.onBackendItemComplete).toHaveBeenCalledWith('local-1', 1); + expect(harness.progressImage.clear).not.toHaveBeenCalled(); + + finishRouting(); + await routingPromise; + await Promise.resolve(); + + expect(harness.progressImage.clear).toHaveBeenCalledWith({ itemIndex: 1, queueItemId: 'local-1' }); + }); + + it('notifies when one backend item in a batch completes', async () => { + harness.callbacks.onBackendItemComplete = vi.fn(); + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 2, + itemIds: [1, 2], + requested: 2, + }); + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + + expect(harness.callbacks.onBackendItemComplete).toHaveBeenCalledWith('local-1', 1); + }); + + it('routes progress images to the active image slot inside a submitted batch', async () => { + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 2, + itemIds: [1, 2], + requested: 2, + }); + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + + harness.socket.fire('invocation_progress', { + ...createStatusEvent({ item_id: 2 }), + image: { dataURL: 'data:image/png;base64,abc', height: 32, width: 64 }, + invocation_source_id: 'denoise', + message: 'Denoising', + percentage: 0.5, + }); + + expect(harness.progressImage.set).toHaveBeenCalledWith( + { dataUrl: 'data:image/png;base64,abc', height: 32, width: 64 }, + { itemIndex: 2, queueItemId: 'local-1' } + ); + }); + + it('ignores untracked queue events before mutating local execution state', () => { + vi.useFakeTimers(); + harness = createHarness({ galleryRefreshCoalesceMs: 400 }); + harness.coordinator.connect(); + harness.callbacks.onGalleryRefresh.mockClear(); + + harness.socket.fire('invocation_started', { + ...createStatusEvent({ item_id: 99 }), + invocation_source_id: 'node-1', + }); + harness.socket.fire('invocation_progress', { + ...createStatusEvent({ item_id: 99 }), + invocation_source_id: 'node-1', + message: 'other user progress', + percentage: 0.5, + }); + harness.socket.fire('invocation_complete', { + ...createStatusEvent({ item_id: 99 }), + invocation_source_id: 'node-1', + result: { type: 'image_output' }, + }); + harness.socket.fire('invocation_error', { + ...createStatusEvent({ item_id: 99 }), + error_message: 'other user failure', + error_type: 'Error', + invocation_source_id: 'node-1', + }); + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 99 })); + + expect(harness.nodeExecution.started).not.toHaveBeenCalled(); + expect(harness.nodeExecution.progress).not.toHaveBeenCalled(); + expect(harness.nodeExecution.completed).not.toHaveBeenCalled(); + expect(harness.nodeExecution.failed).not.toHaveBeenCalled(); + expect(harness.nodeExecution.settleRunning).not.toHaveBeenCalled(); + expect(harness.progressImage.clear).not.toHaveBeenCalled(); + expect(harness.progressImage.set).not.toHaveBeenCalled(); + expect(harness.callbacks.onGalleryRefresh).not.toHaveBeenCalled(); + }); + + it('tracks the active image index inside a submitted batch', async () => { + harness.api.enqueueGenerateGraph.mockResolvedValue({ + batchId: 'batch-1', + enqueued: 2, + itemIds: [1, 2], + requested: 2, + }); + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + + expect(harness.progressEntries.get('local-1')).toEqual({ + activeItemIndex: 1, + completedItemCount: 0, + message: '', + percentage: null, + totalItemCount: 2, + }); + + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + + expect(harness.progressEntries.get('local-1')).toEqual({ + activeItemIndex: 2, + completedItemCount: 1, + message: '', + percentage: null, + totalItemCount: 2, + }); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 2 })); + + await resultsPromise; + }); + + describe('reconcile', () => { + it('adopts pending items the backend already accepted, by origin', async () => { + harness.api.listAllQueueItems.mockResolvedValue([ + createQueueItemDTO({ batch_id: 'batch-9', item_id: 7, origin: buildQueueItemOrigin('local-1') }), + ]); + + const outcomes = await harness.coordinator.reconcile([{ id: 'local-1', status: 'pending' }]); + + expect(outcomes.get('local-1')).toEqual({ backendBatchId: 'batch-9', backendItemIds: [7], kind: 'adopted' }); + expect(harness.api.enqueueGenerateGraph).not.toHaveBeenCalled(); + }); + + it('resumes running items and settles them from their listed terminal status', async () => { + harness.api.getQueueItem.mockResolvedValue(createQueueItemDTO({ item_id: 7, status: 'completed' })); + + const outcomes = await harness.coordinator.reconcile([{ backendItemIds: [7], id: 'local-1', status: 'running' }]); + + expect(outcomes.get('local-1')).toEqual({ kind: 'resumed' }); + expect(harness.api.listAllQueueItems).not.toHaveBeenCalled(); + + const images = await harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + expect(images.map((image) => image.imageName)).toEqual(['image-7.png']); + }); + + it('marks running items missing when their backend items vanished', async () => { + harness.api.getQueueItem.mockRejectedValue(new ApiError('not found', 404)); + + const outcomes = await harness.coordinator.reconcile([{ backendItemIds: [7], id: 'local-1', status: 'running' }]); + + expect(outcomes.get('local-1')).toEqual({ kind: 'missing' }); + expect(harness.api.listAllQueueItems).not.toHaveBeenCalled(); + }); + + it('asks for a fresh enqueue when a pending item left no backend trace', async () => { + harness.api.listAllQueueItems.mockResolvedValue([]); + + const outcomes = await harness.coordinator.reconcile([{ id: 'local-1', status: 'pending' }]); + + expect(outcomes.get('local-1')).toEqual({ kind: 'enqueue' }); + }); + + it('skips the backend round-trip when there is nothing to reconcile', async () => { + const outcomes = await harness.coordinator.reconcile([]); + + expect(outcomes.size).toBe(0); + expect(harness.api.listAllQueueItems).not.toHaveBeenCalled(); + }); + + // Review fix (Task 38, finding 3): the prior "Risk-4" coverage only asserted + // the parse primitive (`parseQueueItemOrigin(utilityOrigin) === null`) in + // isolation. This drives the REAL `reconcile` path with a completed, + // result-carrying `webv2:util:`-origin backend item mixed into a live + // `listAllQueueItems` response, proving the coordinator itself — not just the + // helper it calls — never adopts it into a project queue item (which is the + // only way a utility item's images could ever reach `routeQueueItemResults` + // and land in canvas staging or the gallery). + it('never adopts a completed, result-carrying utility-origin item into a project queue item', async () => { + harness.api.listAllQueueItems.mockResolvedValue([ + createQueueItemDTO({ + batch_id: 'util-batch', + item_id: 99, + origin: buildUtilityQueueItemOrigin('util-run-1'), + session: { results: { n1: { image: { image_name: 'util-result.png' }, type: 'image_output' } } }, + status: 'completed', + }), + ]); + + const outcomes = await harness.coordinator.reconcile([{ id: 'local-1', status: 'pending' }]); + + // The utility item parses to no local queue item id, so it can never be + // bucketed under 'local-1' by origin: the pending project item finds no + // backend trace of its own and is asked to enqueue fresh — never + // "adopted" with the utility item's id, and never routed anywhere. + expect(outcomes.get('local-1')).toEqual({ kind: 'enqueue' }); + expect(harness.api.getQueueItemResultImages).not.toHaveBeenCalled(); + expect(harness.api.getQueueItem).not.toHaveBeenCalledWith(99); + }); + }); + + it('settles missed events through the safety sweep on reconnect', async () => { + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + // The completion event was lost to a disconnect; the reconnect sweep + // re-checks every outstanding item. + harness.api.getQueueItem.mockResolvedValue(createQueueItemDTO({ item_id: 1, status: 'completed' })); + harness.socket.fire('connect', undefined); + + const images = await resultsPromise; + + expect(images.map((image) => image.imageName)).toEqual(['image-1.png']); + }); + + it('fails runs whose backend items were pruned (404) during a sweep', async () => { + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + const resultsPromise = harness.coordinator.waitForResults('local-1', '2026-06-10T00:00:00Z'); + + harness.api.getQueueItem.mockRejectedValue(new ApiError('not found', 404)); + harness.socket.fire('connect', undefined); + + await expect(resultsPromise).rejects.toThrow('no longer on the backend queue'); + }); + + it('prefers precise item ids for cancellation, falling back to batch id', async () => { + await harness.coordinator.cancelRun({ backendBatchId: 'batch-1', backendItemIds: [1, 2] }); + + expect(harness.api.cancelQueueItems).toHaveBeenCalledWith([1, 2]); + expect(harness.api.cancelQueueItemsByBatchIds).not.toHaveBeenCalled(); + + await harness.coordinator.cancelRun({ backendBatchId: 'batch-2' }); + + expect(harness.api.cancelQueueItemsByBatchIds).toHaveBeenCalledWith(['batch-2']); + }); + + it('treats stale missing backend items as already cancelled', async () => { + harness.api.cancelQueueItems.mockRejectedValue( + new ApiError('Queue item with id 42 not found in queue default', 404) + ); + + await expect(harness.coordinator.cancelRun({ backendItemIds: [42] })).resolves.toBeUndefined(); + }); + + it('detaches socket listeners after dispose', async () => { + harness.coordinator.connect(); + + await harness.coordinator.submitGenerate('local-1', generateRequest); + harness.coordinator.dispose(); + + harness.socket.fire('queue_item_status_changed', createStatusEvent({ item_id: 1 })); + + expect(harness.nodeExecution.settleRunning).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.ts b/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.ts new file mode 100644 index 00000000000..71795c3ca5c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.ts @@ -0,0 +1,684 @@ +import type { + EnqueueGenerateRequest, + EnqueueGenerateResult, + EnqueueWorkflowRequest, + ImageDTO, + QueueItemDTO, +} from '@workbench/generation/types'; +import type { BackendConnectionStatus } from '@workbench/types'; + +import { + cancelQueueItems, + cancelQueueItemsByBatchIds, + enqueueGenerateGraph, + enqueueWorkflowGraph, + getQueueItem, + getQueueItemResultImages, + listAllQueueItems, + type QueueItemResultImageOptions, +} from '@workbench/generation/api'; + +import type { SocketHub } from './socketHub'; + +import { + isTerminalBackendStatus, + parseQueueItemOrigin, + type InvocationCompleteEvent, + type InvocationErrorEvent, + type InvocationProgressEvent, + type InvocationStartedEvent, + type QueueItemStatusChangedEvent, + type TerminalBackendQueueItemStatus, +} from './events'; +import { ApiError } from './http'; +import { modelLoadStore } from './modelLoadStore'; +import { nodeExecutionStore, type NodeExecutionSink } from './nodeExecutionStore'; +import { progressImageStore, type ProgressImageSink, type ProgressImageTarget } from './progressImageStore'; +import { queueItemProgressStore, type QueueItemProgressSink } from './progressStore'; + +const GALLERY_REFRESH_COALESCE_MS = 400; +const SAFETY_SWEEP_INTERVAL_MS = 30_000; +const TERMINAL_EVENT_BUFFER_LIMIT = 256; + +export interface QueueCoordinatorApi { + cancelQueueItems: typeof cancelQueueItems; + cancelQueueItemsByBatchIds: typeof cancelQueueItemsByBatchIds; + enqueueGenerateGraph: typeof enqueueGenerateGraph; + enqueueWorkflowGraph: typeof enqueueWorkflowGraph; + getQueueItem: typeof getQueueItem; + getQueueItemResultImages: typeof getQueueItemResultImages; + listAllQueueItems: typeof listAllQueueItems; +} + +export interface QueueCoordinatorCallbacks { + /** Fired when one backend item in a local batch completes, before the whole local batch necessarily settles. */ + onBackendItemComplete?(localQueueItemId: string, backendItemId: number): void | Promise; + /** Fired when one backend item in a local batch is canceled. */ + onBackendItemCancelled?(localQueueItemId: string, backendItemId: number): void; + /** Coalesced signal that completed generations may have added gallery images. */ + onGalleryRefresh(): void; +} + +type TerminalOutcome = { status: 'completed' } | { status: 'failed'; error: string } | { status: 'canceled' }; + +/** Thrown by `waitForResults` when the backend reports the run was canceled. */ +export class QueueItemCancelledError extends Error { + constructor(localQueueItemId: string) { + super(`Queue item ${localQueueItemId} was canceled.`); + this.name = 'QueueItemCancelledError'; + } +} + +export interface ReconcileInput { + id: string; + status: 'pending' | 'running'; + backendItemIds?: number[]; + backendBatchId?: string; +} + +export type ReconcileOutcome = + /** A pending item the backend already accepted before the reload; do not re-enqueue. */ + | { kind: 'adopted'; backendItemIds: number[]; backendBatchId?: string } + /** A running item whose backend items were found again; its results are awaitable. */ + | { kind: 'resumed' } + /** A running item whose backend items no longer exist (queue cleared or pruned). */ + | { kind: 'missing' } + /** A pending item the backend has never seen; submit it normally. */ + | { kind: 'enqueue' }; + +export interface CancelRunRequest { + backendBatchId?: string; + backendItemIds?: number[]; +} + +export interface QueueCoordinator { + connect(): void; + dispose(): void; + /** + * Match persisted pending/running queue items against the live backend queue + * so a reload neither double-submits nor orphans work. Adopted and resumed + * items are tracked and can be awaited with `waitForResults`. + */ + reconcile(items: ReconcileInput[]): Promise>; + /** Enqueue a generate batch and track its backend items for event-driven settlement. */ + submitGenerate(localQueueItemId: string, request: EnqueueGenerateRequest): Promise; + /** Enqueue a compiled workflow graph and track its backend items the same way. */ + submitWorkflow(localQueueItemId: string, request: EnqueueWorkflowRequest): Promise; + /** + * Resolve once every backend item of the run reaches a terminal status — + * driven by socket events, with a slow safety sweep as the only polling. + * Resolves with the result images, throws on failure, and throws + * `QueueItemCancelledError` on backend-side cancellation. + */ + waitForResults( + localQueueItemId: string, + queuedAt: string, + options?: QueueItemResultImageOptions + ): Promise; + cancelRun(request: CancelRunRequest): Promise; +} + +interface RunState { + backendItemIds: number[]; + backendBatchId?: string; + outcomePromises: Promise[]; +} + +interface RunProgressState { + activeBackendItemId?: number; + backendItemIds: number[]; + cancelledBackendItemIds: Set; + completedBackendItemIds: Set; + message: string; + percentage: number | null; +} + +interface WaitState { + localQueueItemId: string; + settle: (outcome: TerminalOutcome) => void; +} + +const defaultApi: QueueCoordinatorApi = { + cancelQueueItems, + cancelQueueItemsByBatchIds, + enqueueGenerateGraph, + enqueueWorkflowGraph, + getQueueItem, + getQueueItemResultImages, + listAllQueueItems, +}; + +const toTerminalOutcome = ( + status: TerminalBackendQueueItemStatus, + error?: string | null, + errorType?: string | null +): TerminalOutcome => { + if (status === 'completed') { + return { status: 'completed' }; + } + + if (status === 'failed') { + return { error: error ?? errorType ?? 'Generation failed.', status: 'failed' }; + } + + return { status: 'canceled' }; +}; + +export const createQueueCoordinator = ( + callbacks: QueueCoordinatorCallbacks, + options: { + hub: Pick; + api?: Partial; + galleryRefreshCoalesceMs?: number; + nodeExecution?: NodeExecutionSink; + progress?: QueueItemProgressSink; + progressImage?: ProgressImageSink; + sweepIntervalMs?: number; + } +): QueueCoordinator => { + const api: QueueCoordinatorApi = { ...defaultApi, ...options.api }; + const hub = options.hub; + const progress = options.progress ?? queueItemProgressStore; + const nodeExecution = options.nodeExecution ?? nodeExecutionStore; + const progressImage = options.progressImage ?? progressImageStore; + const galleryRefreshCoalesceMs = options.galleryRefreshCoalesceMs ?? GALLERY_REFRESH_COALESCE_MS; + const sweepIntervalMs = options.sweepIntervalMs ?? SAFETY_SWEEP_INTERVAL_MS; + + const runs = new Map(); + const runProgress = new Map(); + const waits = new Map(); + /** + * Terminal events that arrived for items nobody tracks yet. Closes the race + * where a very fast generation finishes between `enqueue_batch` resolving + * and the run being registered. + */ + const recentTerminalOutcomes = new Map(); + + const detachers: Array<() => void> = []; + let isAttached = false; + let isDisposed = false; + let galleryRefreshTimer: ReturnType | null = null; + let sweepTimer: ReturnType | null = null; + let isSweeping = false; + + const scheduleGalleryRefresh = (): void => { + if (isDisposed || galleryRefreshTimer !== null) { + return; + } + + galleryRefreshTimer = setTimeout(() => { + galleryRefreshTimer = null; + callbacks.onGalleryRefresh(); + }, galleryRefreshCoalesceMs); + }; + + const bufferTerminalOutcome = (backendItemId: number, outcome: TerminalOutcome): void => { + recentTerminalOutcomes.delete(backendItemId); + recentTerminalOutcomes.set(backendItemId, outcome); + + while (recentTerminalOutcomes.size > TERMINAL_EVENT_BUFFER_LIMIT) { + const oldestId = recentTerminalOutcomes.keys().next().value; + + if (oldestId === undefined) { + return; + } + + recentTerminalOutcomes.delete(oldestId); + } + }; + + const publishRunProgress = (localQueueItemId: string): void => { + const state = runProgress.get(localQueueItemId); + + if (!state) { + return; + } + + const terminalBackendItemIds = new Set([...state.completedBackendItemIds, ...state.cancelledBackendItemIds]); + const activeBackendItemId = + state.activeBackendItemId !== undefined && !terminalBackendItemIds.has(state.activeBackendItemId) + ? state.activeBackendItemId + : undefined; + const activeItemIndex = activeBackendItemId + ? state.backendItemIds.indexOf(activeBackendItemId) + 1 + : Math.min(terminalBackendItemIds.size + 1, state.backendItemIds.length); + + progress.set(localQueueItemId, { + activeItemIndex: Math.max(1, activeItemIndex), + completedItemCount: terminalBackendItemIds.size, + message: state.message, + percentage: state.percentage, + totalItemCount: state.backendItemIds.length, + }); + }; + + const getProgressImageTarget = (localQueueItemId: string, backendItemId: number): ProgressImageTarget => { + const backendItemIds = runProgress.get(localQueueItemId)?.backendItemIds ?? [backendItemId]; + const itemIndex = backendItemIds.indexOf(backendItemId); + + return { itemIndex: itemIndex === -1 ? 1 : itemIndex + 1, queueItemId: localQueueItemId }; + }; + + const settleWait = (backendItemId: number, outcome: TerminalOutcome): void => { + const wait = waits.get(backendItemId); + + if (!wait) { + bufferTerminalOutcome(backendItemId, outcome); + return; + } + + waits.delete(backendItemId); + const progressTarget = getProgressImageTarget(wait.localQueueItemId, backendItemId); + const clearProgressImage = (): void => progressImage.clear(progressTarget); + const state = runProgress.get(wait.localQueueItemId); + + if (state) { + state.activeBackendItemId = undefined; + if (outcome.status === 'completed') { + state.completedBackendItemIds.add(backendItemId); + } + if (outcome.status === 'canceled') { + state.cancelledBackendItemIds.add(backendItemId); + } + state.message = ''; + state.percentage = null; + publishRunProgress(wait.localQueueItemId); + } + + if (outcome.status === 'completed') { + const routingPromise = callbacks.onBackendItemComplete?.(wait.localQueueItemId, backendItemId); + + if (routingPromise) { + void Promise.resolve(routingPromise) + .finally(clearProgressImage) + .catch(() => undefined); + } else { + clearProgressImage(); + } + } + + if (outcome.status === 'canceled') { + clearProgressImage(); + callbacks.onBackendItemCancelled?.(wait.localQueueItemId, backendItemId); + } + + if (outcome.status === 'failed') { + clearProgressImage(); + } + + wait.settle(outcome); + }; + + const settleFromQueueItem = (queueItem: QueueItemDTO): void => { + if (queueItem.status !== 'pending' && queueItem.status !== 'in_progress') { + settleWait(queueItem.item_id, toTerminalOutcome(queueItem.status, queueItem.error_message, queueItem.error_type)); + } + }; + + const isTrackedEvent = (event: { item_id: number }): boolean => waits.has(event.item_id); + + const trackBackendItem = (localQueueItemId: string, backendItemId: number): Promise => { + const bufferedOutcome = recentTerminalOutcomes.get(backendItemId); + + if (bufferedOutcome) { + recentTerminalOutcomes.delete(backendItemId); + + return Promise.resolve(bufferedOutcome); + } + + return new Promise((settle) => { + waits.set(backendItemId, { localQueueItemId, settle }); + }); + }; + + const beginRun = (localQueueItemId: string, backendItemIds: number[], backendBatchId?: string): void => { + runs.set(localQueueItemId, { + backendBatchId, + backendItemIds, + outcomePromises: backendItemIds.map((backendItemId) => trackBackendItem(localQueueItemId, backendItemId)), + }); + + runProgress.set(localQueueItemId, { + backendItemIds, + cancelledBackendItemIds: new Set(), + completedBackendItemIds: new Set(), + message: '', + percentage: null, + }); + publishRunProgress(localQueueItemId); + }; + + /** Slow safety net for events lost to disconnects; runs on reconnect and on a long interval. */ + const sweep = async (): Promise => { + if (isSweeping || waits.size === 0) { + return; + } + + isSweeping = true; + + try { + await Promise.all( + [...waits.keys()].map(async (backendItemId) => { + try { + settleFromQueueItem(await api.getQueueItem(backendItemId)); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + settleWait(backendItemId, { + error: `Queue item ${backendItemId} is no longer on the backend queue.`, + status: 'failed', + }); + } + } + }) + ); + } finally { + isSweeping = false; + } + }; + + const handleStatusChanged = (event: QueueItemStatusChangedEvent): void => { + if (!isTerminalBackendStatus(event.status)) { + return; + } + + if (!isTrackedEvent(event)) { + bufferTerminalOutcome(event.item_id, toTerminalOutcome(event.status, event.error_message, event.error_type)); + return; + } + + nodeExecution.settleRunning(); + settleWait(event.item_id, toTerminalOutcome(event.status, event.error_message, event.error_type)); + + if (event.status === 'completed') { + scheduleGalleryRefresh(); + } + }; + + const handleProgress = (event: InvocationProgressEvent): void => { + const wait = waits.get(event.item_id); + + if (!wait) { + return; + } + + nodeExecution.progress(event.invocation_source_id, event.percentage, event.message); + + if (event.image?.dataURL) { + progressImage.set( + { dataUrl: event.image.dataURL, height: event.image.height, width: event.image.width }, + getProgressImageTarget(wait.localQueueItemId, event.item_id) + ); + } + + const state = runProgress.get(wait.localQueueItemId); + + if (state) { + state.activeBackendItemId = event.item_id; + state.message = event.message; + state.percentage = event.percentage; + publishRunProgress(wait.localQueueItemId); + } else { + progress.set(wait.localQueueItemId, { message: event.message, percentage: event.percentage }); + } + }; + + /** + * React to the shared socket's connection lifecycle. The hub owns the socket, + * the connection store, and `modelLoadStore.reset()`; the coordinator only + * clears its generation-scoped stores and, on (re)connect, schedules a gallery + * refresh and sweeps for events missed while disconnected. + */ + const handleConnectionChange = (status: BackendConnectionStatus): void => { + progress.clearAll?.(); + nodeExecution.clearAll(); + + if (status === 'connected') { + scheduleGalleryRefresh(); + void sweep(); + } + }; + + /** Attach generation listeners to the shared socket hub. */ + const connect = (): void => { + if (isDisposed || isAttached) { + return; + } + + isAttached = true; + + detachers.push( + hub.on('queue_item_status_changed', handleStatusChanged), + hub.on('invocation_progress', handleProgress), + hub.on('invocation_started', (event: InvocationStartedEvent) => { + if (!isTrackedEvent(event)) { + return; + } + + nodeExecution.started(event); + }), + hub.on('invocation_complete', (event: InvocationCompleteEvent) => { + if (!isTrackedEvent(event)) { + return; + } + + nodeExecution.completed(event); + }), + hub.on('invocation_error', (event: InvocationErrorEvent) => { + if (!isTrackedEvent(event)) { + return; + } + + nodeExecution.failed(event); + }), + hub.on('model_load_started', (payload: never) => { + modelLoadStore.started(payload); + }), + hub.on('model_load_complete', (payload: never) => { + modelLoadStore.completed(payload); + }) + ); + + // Fires synchronously with the current status, so attaching after the hub + // has already connected still triggers the initial clear + sweep. + detachers.push(hub.onConnectionChange(handleConnectionChange)); + + sweepTimer = setInterval(() => { + void sweep(); + }, sweepIntervalMs); + }; + + /** Detach generation listeners; the hub keeps the socket alive. */ + const dispose = (): void => { + isDisposed = true; + + for (const detach of detachers) { + detach(); + } + + detachers.length = 0; + + if (galleryRefreshTimer !== null) { + clearTimeout(galleryRefreshTimer); + galleryRefreshTimer = null; + } + + if (sweepTimer !== null) { + clearInterval(sweepTimer); + sweepTimer = null; + } + }; + + const reconcile = async (items: ReconcileInput[]): Promise> => { + const outcomes = new Map(); + + if (items.length === 0) { + return outcomes; + } + + const backendItems = items.every((item) => item.backendItemIds?.length) + ? ( + await Promise.all( + items + .flatMap((item) => item.backendItemIds ?? []) + .map(async (itemId) => { + try { + return await api.getQueueItem(itemId); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return undefined; + } + + throw error; + } + }) + ) + ).filter((item) => item !== undefined) + : await api.listAllQueueItems(); + const backendItemsById = new Map(backendItems.map((item) => [item.item_id, item])); + const backendItemsByLocalId = new Map(); + + for (const backendItem of backendItems) { + const localQueueItemId = parseQueueItemOrigin(backendItem.origin); + + if (localQueueItemId) { + backendItemsByLocalId.set(localQueueItemId, [ + ...(backendItemsByLocalId.get(localQueueItemId) ?? []), + backendItem, + ]); + } + } + + for (const item of items) { + const knownBackendItems = item.backendItemIds?.length + ? item.backendItemIds.map((backendItemId) => backendItemsById.get(backendItemId)) + : (backendItemsByLocalId.get(item.id) ?? []); + const foundBackendItems = knownBackendItems.filter((backendItem) => backendItem !== undefined); + + if (foundBackendItems.length !== knownBackendItems.length || foundBackendItems.length === 0) { + // A pending item with no backend trace was never accepted and is safe + // to submit; a running item with (partially) vanished backend items is + // unrecoverable. + outcomes.set(item.id, item.status === 'pending' ? { kind: 'enqueue' } : { kind: 'missing' }); + continue; + } + + const backendItemIds = foundBackendItems.map((backendItem) => backendItem.item_id); + const backendBatchId = item.backendBatchId ?? foundBackendItems[0]?.batch_id; + + beginRun(item.id, backendItemIds, backendBatchId); + + for (const backendItem of foundBackendItems) { + settleFromQueueItem(backendItem); + } + + outcomes.set( + item.id, + item.status === 'running' ? { kind: 'resumed' } : { backendBatchId, backendItemIds, kind: 'adopted' } + ); + } + + return outcomes; + }; + + const submitGenerate = async ( + localQueueItemId: string, + request: EnqueueGenerateRequest + ): Promise => { + const result = await api.enqueueGenerateGraph(request); + + if (result.enqueued === 0) { + throw new Error('The backend queue did not accept this generation. The queue may be full.'); + } + + if (result.requested !== result.enqueued) { + throw new Error(`The backend queue accepted ${result.enqueued} of ${result.requested} requested items.`); + } + + beginRun(localQueueItemId, result.itemIds, result.batchId); + + return result; + }; + + const submitWorkflow = async ( + localQueueItemId: string, + request: EnqueueWorkflowRequest + ): Promise => { + const result = await api.enqueueWorkflowGraph(request); + + if (result.enqueued === 0) { + throw new Error('The backend queue did not accept this workflow. The queue may be full.'); + } + + if (result.requested !== result.enqueued) { + throw new Error(`The backend queue accepted ${result.enqueued} of ${result.requested} requested items.`); + } + + beginRun(localQueueItemId, result.itemIds, result.batchId); + + return result; + }; + + const waitForResults = async ( + localQueueItemId: string, + queuedAt: string, + options?: QueueItemResultImageOptions + ): Promise => { + const run = runs.get(localQueueItemId); + + if (!run) { + throw new Error(`Queue item ${localQueueItemId} has no tracked backend run.`); + } + + try { + const outcomes = await Promise.all(run.outcomePromises); + const failure = outcomes.find((outcome) => outcome.status === 'failed'); + + if (failure) { + throw new Error(failure.error); + } + + const completedBackendItemIds = run.backendItemIds.filter( + (_backendItemId, index) => outcomes[index]?.status === 'completed' + ); + + if (completedBackendItemIds.length === 0 && outcomes.some((outcome) => outcome.status === 'canceled')) { + throw new QueueItemCancelledError(localQueueItemId); + } + + const imagesPerItem = await Promise.all( + completedBackendItemIds.map((backendItemId) => + options + ? api.getQueueItemResultImages(backendItemId, localQueueItemId, queuedAt, options) + : api.getQueueItemResultImages(backendItemId, localQueueItemId, queuedAt) + ) + ); + + return imagesPerItem.flat(); + } finally { + runs.delete(localQueueItemId); + runProgress.delete(localQueueItemId); + progress.clear(localQueueItemId); + } + }; + + const cancelRun = async ({ backendBatchId, backendItemIds }: CancelRunRequest): Promise => { + try { + if (backendItemIds?.length) { + await api.cancelQueueItems(backendItemIds); + return; + } + + if (backendBatchId) { + await api.cancelQueueItemsByBatchIds([backendBatchId]); + } + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return; + } + + throw error; + } + }; + + return { cancelRun, connect, dispose, reconcile, submitGenerate, submitWorkflow, waitForResults }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/backend/socketHub.test.ts b/invokeai/frontend/webv2/src/workbench/backend/socketHub.test.ts new file mode 100644 index 00000000000..3f2a574786b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/socketHub.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { BackendSocket } from './socketHub'; + +import { getConnectionStatus } from './connectionStore'; +import { modelLoadStore } from './modelLoadStore'; +import { createSocketHub } from './socketHub'; + +class FakeSocket implements BackendSocket { + readonly emitted: { event: string; payload: unknown }[] = []; + private readonly handlers = new Map void>>(); + + on(event: string, handler: (payload: never) => void): void { + let handlers = this.handlers.get(event); + + if (!handlers) { + handlers = new Set(); + this.handlers.set(event, handlers); + } + + handlers.add(handler); + } + + off(event: string, handler: (payload: never) => void): void { + this.handlers.get(event)?.delete(handler); + } + + emit(event: string, payload: unknown): void { + this.emitted.push({ event, payload }); + } + + connect(): void { + this.fire('connect', undefined); + } + + disconnect(): void { + this.fire('disconnect', 'io client disconnect'); + } + + fire(event: string, payload: unknown): void { + for (const handler of this.handlers.get(event) ?? []) { + (handler as (value: unknown) => void)(payload); + } + } +} + +describe('socketHub', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('connects, reports connected status, and subscribes to the queue', () => { + const socket = new FakeSocket(); + const hub = createSocketHub({ createSocket: () => socket }); + const onChange = vi.fn(); + + hub.onConnectionChange(onChange); + onChange.mockClear(); + hub.connect(); + + expect(getConnectionStatus().status).toBe('connected'); + expect(onChange).toHaveBeenNthCalledWith(1, 'connecting', undefined); + expect(onChange).toHaveBeenLastCalledWith('connected', undefined); + expect(socket.emitted).toContainEqual({ event: 'subscribe_queue', payload: { queue_id: 'default' } }); + }); + + it('fires the current status synchronously on subscribe', () => { + const socket = new FakeSocket(); + const hub = createSocketHub({ createSocket: () => socket }); + + hub.connect(); + + const late = vi.fn(); + + hub.onConnectionChange(late); + + expect(late).toHaveBeenCalledWith('connected', undefined); + }); + + it('is idempotent — repeated connect keeps one socket', () => { + let created = 0; + const hub = createSocketHub({ + createSocket: () => { + created += 1; + + return new FakeSocket(); + }, + }); + + hub.connect(); + hub.connect(); + + expect(created).toBe(1); + }); + + it('delivers consumer listeners and detaches them on unsubscribe', () => { + const socket = new FakeSocket(); + const hub = createSocketHub({ createSocket: () => socket }); + + hub.connect(); + + const handler = vi.fn(); + const off = hub.on('queue_item_status_changed', handler); + + socket.fire('queue_item_status_changed', { item_id: 1 }); + expect(handler).toHaveBeenCalledTimes(1); + + off(); + socket.fire('queue_item_status_changed', { item_id: 2 }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('re-binds consumer listeners after a reconnect', () => { + const sockets: FakeSocket[] = []; + const hub = createSocketHub({ + createSocket: () => { + const socket = new FakeSocket(); + + sockets.push(socket); + + return socket; + }, + }); + + hub.connect(); + + const handler = vi.fn(); + + hub.on('queue_item_status_changed', handler); + hub.disconnect(); + hub.connect(); + + expect(sockets).toHaveLength(2); + + sockets[1]!.fire('queue_item_status_changed', { item_id: 1 }); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('resets model loads and reports a disconnect', () => { + const resetSpy = vi.spyOn(modelLoadStore, 'reset'); + const socket = new FakeSocket(); + const hub = createSocketHub({ createSocket: () => socket }); + + hub.connect(); + expect(resetSpy).toHaveBeenCalled(); + + socket.fire('disconnect', 'transport close'); + + expect(getConnectionStatus().status).toBe('disconnected'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/backend/socketHub.ts b/invokeai/frontend/webv2/src/workbench/backend/socketHub.ts new file mode 100644 index 00000000000..344d236bb80 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/backend/socketHub.ts @@ -0,0 +1,150 @@ +import type { BackendConnectionStatus } from '@workbench/types'; + +import { io } from 'socket.io-client'; + +import { setConnectionStatus } from './connectionStore'; +import { getAuthToken, getBackendSocketUrl } from './http'; +import { modelLoadStore } from './modelLoadStore'; + +const SOCKET_PATH = '/ws/socket.io'; + +/** + * The minimal Socket.IO surface the hub uses; tests substitute a fake. + */ +export interface BackendSocket { + on(event: string, handler: (payload: never) => void): unknown; + off(event: string, handler: (payload: never) => void): unknown; + emit(event: string, payload: unknown): unknown; + connect(): unknown; + disconnect(): unknown; +} + +export type ConnectionListener = (status: BackendConnectionStatus, error?: string) => void; + +/** + * Owns the single backend socket for the whole authenticated app. It is only a + * transport/status hub; feature runtimes attach their own listeners so admin + * model code and editor queue code do not leak into the base Launchpad bundle. + */ +export interface SocketHub { + /** Idempotent: connects the single socket if one is not already live. */ + connect(): void; + /** Tears down the socket (identity change / logout); a later `connect` rebuilds it. */ + disconnect(): void; + /** Attach a raw socket listener; returns an unsubscribe. Survives socket recreation. */ + on(event: string, handler: (payload: never) => void): () => void; + emit(event: string, payload: unknown): void; + /** Subscribe to connection transitions; fires synchronously with current status on subscribe. */ + onConnectionChange(handler: ConnectionListener): () => void; +} + +const createDefaultSocket = (): BackendSocket => { + const token = getAuthToken(); + + // Socket.IO's generic `off` overload does not structurally match our minimal + // facade; the socket satisfies the surface we actually use, so narrow it here. + return io(getBackendSocketUrl(), { + auth: token ? { token } : undefined, + autoConnect: false, + extraHeaders: token ? { Authorization: `Bearer ${token}` } : undefined, + path: SOCKET_PATH, + timeout: 60000, + }) as unknown as BackendSocket; +}; + +export const createSocketHub = (options: { createSocket?: () => BackendSocket } = {}): SocketHub => { + const createSocket = options.createSocket ?? createDefaultSocket; + + let socket: BackendSocket | null = null; + let status: BackendConnectionStatus = 'connecting'; + let lastError: string | undefined; + + /** Registered consumer listeners, kept so they can be re-bound to a fresh socket. */ + const eventHandlers = new Map void>>(); + const connectionListeners = new Set(); + + const publishStatus = (next: BackendConnectionStatus, error?: string): void => { + status = next; + lastError = error; + setConnectionStatus(next, error); + + for (const listener of connectionListeners) { + listener(next, error); + } + }; + + const connect = (): void => { + if (socket) { + return; + } + + const nextSocket = createSocket(); + + socket = nextSocket; + publishStatus('connecting'); + + nextSocket.on('connect', () => { + modelLoadStore.reset(); + publishStatus('connected'); + nextSocket.emit('subscribe_queue', { queue_id: 'default' }); + }); + nextSocket.on('connect_error', (error: { message: string }) => { + modelLoadStore.reset(); + publishStatus('disconnected', error.message); + }); + nextSocket.on('disconnect', (reason: string) => { + modelLoadStore.reset(); + publishStatus('disconnected', reason); + }); + + // Re-bind consumer listeners so they survive a socket recreation. + for (const [event, handlers] of eventHandlers) { + for (const handler of handlers) { + nextSocket.on(event, handler); + } + } + + nextSocket.connect(); + }; + + const disconnect = (): void => { + socket?.disconnect(); + socket = null; + publishStatus('connecting'); + }; + + const on = (event: string, handler: (payload: never) => void): (() => void) => { + let handlers = eventHandlers.get(event); + + if (!handlers) { + handlers = new Set(); + eventHandlers.set(event, handlers); + } + + handlers.add(handler); + socket?.on(event, handler); + + return () => { + eventHandlers.get(event)?.delete(handler); + socket?.off(event, handler); + }; + }; + + const emit = (event: string, payload: unknown): void => { + socket?.emit(event, payload); + }; + + const onConnectionChange = (handler: ConnectionListener): (() => void) => { + connectionListeners.add(handler); + handler(status, lastError); + + return () => { + connectionListeners.delete(handler); + }; + }; + + return { connect, disconnect, emit, on, onConnectionChange }; +}; + +/** The app-wide socket hub singleton, connected by `SocketHubRuntime`. */ +export const socketHub = createSocketHub(); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md b/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md new file mode 100644 index 00000000000..e31fbbba4cb --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md @@ -0,0 +1,148 @@ +# Canvas Engine Architecture + +## Ownership invariant + +The workbench reducer owns the serializable canvas contract. A per-project canvas engine owns pixels, decoded images, rendering, interaction, history, transient editing state, and caller-owned raster snapshots. Engine-owned objects never enter reducer actions or persisted project state. + +Canvas mutations are explicitly project-addressed. `CanvasProjectMutation` is the complete reducer mutation vocabulary, and the only `WorkbenchAction` that carries one is: + +```ts +{ + type: 'applyCanvasProjectMutation'; + projectId: string; + mutation: CanvasProjectMutation; +} +``` + +The workbench reducer handles this envelope only through `updateProjectById`. There are no canvas actions whose target is inferred from the active project. + +Each engine receives a `CanvasProjectMutationPort` bound to its immutable `projectId`. The port exposes only `getCanvasState()`, `subscribe()`, and `dispatch(mutation)`. It neither exposes the global workbench dispatcher nor follows active-project changes. Widgets use `useCanvasProjectMutationDispatch`, which captures the current project ID and emits the same envelope. Consequently, project switches and colliding layer IDs cannot redirect a delayed engine mutation to another project. + +The port's `dispatch()` returns whether the named project's canvas identity changed. Paint persistence clears a dirty result only after the intended layer accepts the bitmap reference. A rejected update retains dirty pixels for retry; deletion of the layer or project is terminal and discards obsolete persistence work. This applies equally to raster/control paint sources and paint-backed masks. + +## Package and capability boundaries + +`canvas-engine/` is the rendering and editing core. It may import engine-internal modules and shared canvas contracts, mutations, and the project mutation port. It must not import React, widgets, application canvas operations, generation graphs, socket infrastructure, or backend networking. `importBoundaries.test.ts` enforces that rule. + +`canvas-operations/` owns application workflows such as Filter and Select Object. Coordinators may depend on queues, uploads, model selection, and narrow engine capabilities. Their stores are React-free and compatible with `useSyncExternalStore`; React adapters remain under `widgets/`. + +The public engine handle is divided into capability groups such as `CanvasDocumentCapability`, `CanvasExportCapability`, `CanvasLayerCapability`, `CanvasLifecycleCapability`, and the interaction/store capabilities. Production consumers request the smallest aggregate they need. The full `CanvasEngine` type is limited by `fullEngineImports.test.ts` to the registry and composition roots; tests may construct full-engine fakes. The unused `fflate` dependency has been removed. + +`CanvasCoreStores` contains interaction state only. Filter and Select Object snapshots are created and owned by `canvas-operations`; application-session state cannot leak into the core store contract. + +Controllers communicate through constructor-injected ports or public capabilities. They do not reach into another controller's private maps, and the core engine never receives the global workbench dispatcher. + +## Controllers + +`engine.ts` is the core composition root and document-event router. New behavior belongs in a focused controller with explicit dependencies and an idempotent `dispose()` method. + +Current extracted ownership includes: + +- `RasterController`: base layer caches, decoded bitmap leases, derived and adjusted surfaces, rasterization jobs, invalidation, and the unified raster-memory controller. +- `RasterMemoryBudgetController`: base, derived, decoded, detached, and reserved byte accounting plus generation-scoped and operation-owned reservations and pins. +- `RenderController`: render targets, scheduler construction, previews, and render lifecycle. +- `HistoryController`: engine-owned history and inactive byte trimming. +- `PersistenceController`: dirty-bitmap flush barriers and disposal. +- `EditingController`: selection state and exclusive edit-lease lifecycle. +- `InteractionController`: public tool commands and their disposal boundary. +- `LayerController`: guarded layer mutations and thumbnail capability ports. +- `RasterExportController`: guarded layer rasterization, transformed copies, encoding, reservations, and pin leases. +- `PsdExportController`: immutable PSD planning, snapshot capture, reservation, execution, and cancellation. +- `StagedResultController`: guarded staged-candidate acceptance and its history entry. +- `ControlPixelController`, `SelectionImageController`, and `LayerMutationController`: atomic pixel/document publication at their respective boundaries. +- `MaskResultController`, `FilterResultController`, and `GeneratedResultController`: guarded application-result adoption through narrow host ports. + +Controller-local tests instantiate these boundaries with fakes. Integration tests in `engine.test.ts` cover composition, document routing, and cross-controller invariants. + +## Immutable document and raster snapshots + +`CanvasDocumentCapability.captureSnapshot()` returns a `CanvasDocumentSnapshot` or `null`. A snapshot contains a structured clone of the exact `CanvasStateContractV2` and the engine's `documentGeneration`. The engine privately associates it with the reducer canvas identity from which it was captured, so a caller cannot manufacture a current snapshot by copying fields. + +`CanvasExportCapability.captureRasterSnapshot(snapshot, layerIds, options)` requests exactly the required layer caches, temporarily pins them, reserves detached-surface capacity, and copies their pixels into independent surfaces. It returns a typed `ok`, `stale`, `aborted`, `not-ready`, or `over-budget` result. Freshness is checked before and after every asynchronous rasterization boundary and once after all copies are detached. + +An `ok` result contains a `CanvasRasterSnapshot`: the cloned canvas contract, its document generation, a map of detached layer surfaces, and an idempotent `release()`. The snapshot is caller-owned. Its surfaces do not read live caches and remain valid while the engine cools down; cooldown releases generation-scoped reservations and pins but does not invalidate detached snapshots. The caller must release the snapshot in `finally`. Engine disposal releases any snapshots still outstanding. + +This creates a deliberate transaction boundary: + +1. Before detachment completes, a document change makes the capture `stale`; no downstream submission may use a mixture of generations. +2. After a successful invocation detachment, ordinary editing may continue. Composite, upload, graph compilation, and queue submission read only the frozen contract and detached surfaces. +3. A wholesale canvas replacement remains a separate session, identified by `documentRevision`; completed results from an older session are dropped. + +Canvas invocation first crosses the paint-flush barrier, then captures the post-flush document, plans every raster/control/regional composite, and detaches exactly the contributing layers. Composite dedupe writes are staged in transaction-local maps. They are published to the persistent per-engine dedupe cache only after all frozen compositing, uploads, compilation, and queue dispatch complete; stale or failed captures leave no persistent dedupe entries. + +`submitCanvasInvocationSnapshot` carries the exact cloned canvas state used for compilation. `enqueueCompiledSnapshot` clones that supplied state rather than reading the live project canvas. Generated candidates are placed from `queueItem.snapshot.canvas.document.bbox`, so later bbox edits do not move the result. `documentRevision` still prevents results from a replaced canvas session from entering current staging. + +## Frame demand before allocation + +`calculateActiveFrameLayerIds` is a pure pre-allocation pass. For each enabled renderable layer it combines persisted source bounds with any larger live cache rect, applies the committed or transient transform (including rotation), respects isolation, and intersects the result with the document-space viewport. + +The render path computes this set before `ensureLayerCaches`. Only demanded layers are rasterized for the frame. Offscreen enabled layers are not eagerly allocated; they remain on-demand and LRU-evictable, then rasterize again when panning or a transform reveals them. + +Budget enforcement protects only: + +- layers demanded by the active frame; and +- layers explicitly pinned by an in-flight thumbnail, export, snapshot, or other background operation. + +Derived surfaces are evicted first, followed by least-recently-used unprotected base caches. Export and thumbnail callers request and pin their own caches, preserving their behavior without treating all enabled layers as visible. Deterministic tests assert allocation counts and bytes for pan/reveal, rotated bounds, unflushed paint bounds, isolation, transient transforms, eviction, and re-rasterization. + +## Raster resource ownership and memory policy + +The raster soft limit is 512 MiB. `RasterMemoryBudgetController` maintains one snapshot across all material allocation classes: + +- `baseBytes`: layer cache surfaces; +- `derivedBytes`: derived and adjusted display surfaces; +- `decodedBytes`: leased `ImageBitmap` instances; +- `detachedBytes`: caller-owned raster snapshot surfaces; and +- `reservedBytes`: capacity promised to background work but not yet represented by another class. + +Active-frame base surfaces are allowed to exceed the soft limit so the visible frame can render; diagnostics report the overage. Background snapshots, thumbnails, raster exports, transformed/adjusted copies, and PSD exports must reserve sufficient available bytes before allocating and return typed `over-budget` outcomes when they cannot. Surface-cache enforcement subtracts decoded, detached, and reserved bytes before calculating the base/derived allowance. + +Reservations and cache pins are idempotent leases. Cancellable preparation work uses lifecycle-generation leases, which activation, cooldown, and disposal release if the normal `finally` path has not already done so. Once an asynchronous raster composite is in flight, it uses operation-owned reservations and pins instead: lifecycle changes may make its result stale, but cannot erase its accounting while a yielded allocation is still able to resume. The operation releases those leases in `finally`. Detached snapshot accounting is also caller-owned: it follows the snapshot and is released only by `CanvasRasterSnapshot.release()` or engine disposal. + +`DecodedBitmapPool` replaces permanent decoded-image caching. `acquire(imageName, decode, signal)` coalesces concurrent decodes for the same image and returns a short-lived `DecodedBitmapLease`. Each rasterizer releases its lease in `finally`. The bitmap closes after the final lease, a pending decode is aborted when all interested callers cancel, and disposal aborts pending work and closes every resolved bitmap. Pool byte changes feed `decodedBytes`, so decoded images participate in the same budget as surfaces. + +## Derived surfaces and invalidation + +Every display-only pixel effect uses `DerivedSurfaceCache`. A slot is identified by layer ID and effect kind, and guarded by source surface identity, monotonic source version, and a deterministic parameter key. Reuse is safe only when every guard matches. Source replacement therefore cannot publish or reuse a surface produced for an older preview. + +Invalidate only the affected effect when parameters change. Delete every derived slot when a layer is removed, replaced, or its base cache is evicted. Frame-demand culling happens before derived lookup or construction. + +## Edit leases and staged-result acceptance + +Application operations acquire one exclusive lease from `CanvasEditGate`. A lease carries an `AbortSignal`, has an explicit `isCurrent()` freshness check, and is idempotently released. Release, document/project invalidation, cooldown, or disposal aborts and stales the lease. Expected cancellation and stale results use status unions; exceptions represent unexpected failures. + +Staged-result acceptance is engine-owned. The UI enables Accept from interaction capabilities, but `StagedResultController` remains authoritative. It captures a document-edit permit, rejects an active gesture, and verifies the selected candidate's stable key immediately before dispatch. + +The initial `commitStagedImage` mutation atomically inserts the new raster layer, selects it, clears staging, and records the project event. The controller verifies both the reducer postcondition and the document mirror before pushing exactly one engine-history entry. It returns `busy`, `stale`, or `missing` without changing staging or history when any guard fails. + +Undo removes the accepted layer and restores the prior selection. Redo restores the identical layer and selection without recreating the event or staging candidate. The history entry uses failure-atomic replay, and a successful commit uses the normal history `push`, which clears any previous redo stack. Undo intentionally does not return the accepted candidate to staging. + +## PSD export + +PSD export is a planner/executor transaction over immutable snapshots. `PsdExportController` captures one `CanvasDocumentSnapshot`, plans exportable raster layers and their transformed world-space bounds, then obtains a `CanvasRasterSnapshot` including disabled layers. Execution reads only those detached surfaces; it cannot mix live layer caches from different document generations. + +The controller retains the 30,000-pixel side cap and also derives a pixel-area limit from currently available reserved bytes. It accounts for the flattened composite plus each transformed layer surface, budgets eight bytes per required pixel, and reserves the full execution allocation before creating PSD surfaces. A final freshness check occurs before that reservation; once execution starts it uses only frozen pixels. Capture and execution reservations are released in `finally`, and cancellation aborts before the next allocation or side effect. + +The capability returns `exported`, `nothing`, `too-large`, `not-ready`, `over-budget`, `stale`, or `aborted`. The layers widget awaits the promise and maps every non-success result to a user-visible notice. + +## Lifecycle + +Lifecycle states are `active`, `cooling`, `cool`, and `disposed`. Losing the final registry reference immediately detaches input/render targets, cancels background PSD work and rasterization, releases the old generation's reservations and pins, and starts a dirty-pixel flush. Successful, still-current flushes release reconstructible raster caches and trim history. Failed flushes retain dirty pixels and remain retryable; the registry retains the engine and retries after another grace period, disposing it only after a `cooled` result. Reacquisition increments the lifecycle generation so late flush or grace-period work cannot clean up or dispose a live engine. Operation-owned composite leases survive these generation transitions until their owning operation settles. + +Caller-owned `CanvasRasterSnapshot` objects are not reconstructible lifecycle caches. They remain usable through cooldown and continue to count against detached bytes until released. Final engine disposal force-releases them. + +## Browser acceptance + +Node and browser suites are separated by filename and config. `vitest.config.mts` excludes `*.browser.test.ts(x)`; `vitest.browser.config.mts` runs those files through `@vitest/browser-playwright` 4.1.9 in headless Chromium. `test:browser` runs the browser suite and `test:all` runs Node followed by browser tests. CI installs Chromium with `pnpm exec playwright install chromium` before the browser job. + +The real-Canvas2D suite verifies transformed composition, clipping, alpha and blend modes, browser text metrics, PNG blob encoding, `createImageBitmap` decoding, pixel undo/redo, and a small PSD write/read round trip. A React `StrictMode` harness verifies surface attachment, `ResizeObserver` sizing, detachment, registry cooldown/reacquisition, grace-period safety, and project switching. + +## Adding functionality + +- New tool: implement the tool interface under `tools/`, keep durable state in `CanvasProjectMutation`, and expose only required tool capability methods. +- New derived effect: add a stable effect kind/parameter key, construct through `DerivedSurfaceCache`, and test warm hits, precise invalidation, stale source guards, culling, and byte accounting. +- New layer operation: put pure pixel/document math in the core and orchestration in a controller; accept narrow ports, use project-bound mutations, reserve background allocations, and return status unions for expected no-op/stale cases. +- New background raster workflow: capture a document snapshot, plan required layers, reserve and pin before allocation, detach immutable surfaces, release every lease in `finally`, and define whether edits after detachment are allowed. +- New application coordinator: place it under `canvas-operations/`, acquire an edit lease, capture through narrow document/export capabilities, own its session store and cancellation, and publish through preview/layer capabilities. + +Deterministic CI assertions use operation counters and byte totals. Wall-clock timing is informational only. diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/api.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/api.ts new file mode 100644 index 00000000000..0dd22b0ddf2 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/api.ts @@ -0,0 +1,211 @@ +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { + CanvasDocumentContractV2, + CanvasImageRef, + CanvasLayerContract, + CanvasStagingCandidateContract, + CanvasStateContractV2, +} from '@workbench/types'; + +import type { CanvasEditGate } from './editGate'; +import type { EngineStores } from './engineStores'; +import type { RasterCompositeExportRequest, RasterCompositeExportResult } from './exportRasterComposite'; +import type { RasterSurface } from './render/raster'; +import type { Rect, ToolId, Vec2 } from './types'; +import type { Viewport } from './viewport'; + +/** Opaque snapshot identity carried through asynchronous layer operations. */ +export interface LayerExportGuard { + readonly projectId: string; + readonly layerId: string; + readonly layer: CanvasLayerContract; + readonly cacheVersion: number; + readonly documentGeneration: number; +} + +export type CanvasCoreStores = EngineStores; + +export interface CanvasCoreStoreCapability { + readonly stores: CanvasCoreStores; +} + +export interface CanvasSurfaceCapability { + attach(screenCanvas: HTMLCanvasElement, overlayCanvas: HTMLCanvasElement): void; + detach(): void; + resize(cssWidth: number, cssHeight: number, dpr: number): void; +} + +export interface CanvasDocumentCapability { + captureSnapshot(): CanvasDocumentSnapshot | null; + getDocument(): CanvasDocumentContractV2 | null; +} + +/** Immutable reducer canvas state captured at one engine document generation. */ +export interface CanvasDocumentSnapshot { + readonly canvas: CanvasStateContractV2; + readonly documentGeneration: number; +} + +export interface CanvasDetachedLayerSurface { + readonly rect: Rect; + readonly surface: RasterSurface; +} + +/** Caller-owned frozen pixels paired with the exact canvas contract that planned them. */ +export interface CanvasRasterSnapshot extends CanvasDocumentSnapshot { + /** Requested layers confirmed to have no current live or persisted pixels. */ + readonly emptyLayerIds: ReadonlySet; + readonly layerSurfaces: ReadonlyMap; + /** Idempotently releases all detached-pixel memory owned by this snapshot. */ + release(): void; +} + +export type CaptureRasterSnapshotResult = + | { status: 'ok'; snapshot: CanvasRasterSnapshot } + | { status: 'stale' | 'aborted' | 'not-ready' | 'over-budget' }; + +export type PsdExportResult = 'exported' | 'nothing' | 'too-large' | 'not-ready' | 'over-budget' | 'stale' | 'aborted'; + +export interface CanvasPsdExportCapability { + exportRasterLayersToPsd(fileName: string): Promise; +} + +export interface CanvasViewportCapability { + getViewport(): Viewport; + fitToView(): void; + setBboxGrid(size: number): void; +} + +export interface CanvasToolCapability { + setTool(toolId: ToolId, options?: { temporary?: boolean }): void; + stepBrushSize(direction: 1 | -1): void; +} + +export interface CanvasHistoryCapability { + undo(): void; + redo(): void; + clearHistory(): void; +} + +export type LayerThumbnailRequestResult = 'ready' | 'stale' | 'error' | 'missing' | 'unsupported' | 'over-budget'; + +export interface CanvasPreviewCapability { + drawLayerThumbnail(layerId: string, target: HTMLCanvasElement, maxSize: number): boolean; + requestLayerThumbnail(layerId: string): Promise; +} + +export interface CanvasExportCapability { + captureRasterSnapshot( + documentSnapshot: CanvasDocumentSnapshot, + layerIds: readonly string[], + options?: { signal?: AbortSignal; includeDisabled?: boolean } + ): Promise; + captureLayerExportGuard(layerId: string): LayerExportGuard | null; + exportBakedLayerBlob(layerId: string, options?: ExportBakedLayerPixelsOptions): Promise; + exportRasterComposite(request: RasterCompositeExportRequest): Promise; + getCompositeExecutorDeps(): CanvasCompositeExecutorDeps; + hasExportableLayerContent(layerId: string): boolean; + isLayerExportGuardCurrent(guard: LayerExportGuard): boolean; +} + +export type { + RasterCompositeExportRequest, + RasterCompositeExportResult, + RasterCompositeExportSnapshot, +} from './exportRasterComposite'; + +export interface ExportLayerPixelsOptions { + includeDisabled?: boolean; + applyAdjustments?: boolean; + signal?: AbortSignal; +} + +export type ExportBakedLayerPixelsOptions = Omit & { + applyAdjustments?: boolean; +}; + +export type ExportBakedLayerBlobResult = + | { status: 'ok'; blob: Blob; rect: Rect; guard: LayerExportGuard } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' | 'aborted' }; + +export interface CanvasCompositeExecutorDeps { + backend: { + createSurface(width: number, height: number): RasterSurface; + encodeSurface(surface: RasterSurface, type?: string): Promise; + }; + getLayerSurface(layerId: string): Promise<{ surface: RasterSurface; rect: Rect }>; + reserve?( + bytes: number + ): + | { status: 'ok'; lease: { release(): void } } + | { status: 'over-budget'; requestedBytes: number; availableBytes: number }; + uploadImage(blob: Blob): Promise<{ imageName: string; width: number; height: number }>; +} + +export interface CanvasSelectionCapability { + deselect(): void; + eraseSelection(): void; + fillSelection(): void; + getSelectionBounds(): Rect | null; + getSelectionMaskRect(): Rect | null; + invertSelection(): void; + replaceSelectionFromImage( + guard: LayerExportGuard, + image: CanvasImageRef, + rect: Rect, + signal?: AbortSignal + ): Promise; + selectAll(): void; +} + +export type ReplaceSelectionFromImageResult = + | { status: 'selected' } + | { status: 'aborted' | 'missing' | 'locked' | 'stale' | 'unsupported' | 'busy' } + | { status: 'failed'; message: string }; + +export interface CanvasLayerCapability { + applyStructuralPreview(action: CanvasProjectMutation): boolean; + canCommitStructural(): boolean; + commitGeneratedImageResult(options: CommitGeneratedImageOptions): Promise; + commitStagedImage(options: CommitStagedImageOptions): CommitStagedImageResult; + commitStructural(label: string, forward: CanvasProjectMutation, inverse: CanvasProjectMutation): boolean; + invertMask(layerId: string): boolean; +} + +export interface CommitStagedImageOptions { + candidate: CanvasStagingCandidateContract; + selectedImageIndex: number; +} + +export type CommitStagedImageResult = + | { status: 'committed'; layerId: string } + | { status: 'busy' | 'stale' | 'missing' }; + +export type GeneratedImageTarget = 'replace' | 'copy-raster' | 'copy-control'; + +export interface CommitGeneratedImageOptions { + guard: LayerExportGuard; + image: CanvasImageRef; + origin: Vec2; + target: GeneratedImageTarget; + historyLabel?: string; + copyLayerName?: string; + signal?: AbortSignal; +} + +export type CommitGeneratedImageResult = + | { status: 'committed'; layerId: string } + | { status: 'missing' | 'locked' | 'stale' | 'unsupported' | 'busy' | 'aborted' } + | { status: 'failed'; message: string }; + +export type CanvasLifecycleState = 'active' | 'cooling' | 'cool' | 'disposed'; + +export interface CanvasLifecycleCapability { + activate(): void; + beginCooldown(): Promise<'cooled' | 'dirty'>; + dispose(): void; + getLifecycleState(): CanvasLifecycleState; + flushPendingUploads(): Promise; +} + +export type CanvasEditCapability = CanvasEditGate; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/applicationHost.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/applicationHost.ts new file mode 100644 index 00000000000..b9b367e1700 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/applicationHost.ts @@ -0,0 +1,85 @@ +import type { + CanvasCompositeExecutorDeps, + CommitGeneratedImageOptions, + CommitGeneratedImageResult, + ExportBakedLayerBlobResult, + ExportLayerPixelsOptions, + LayerExportGuard, + ReplaceSelectionFromImageResult, +} from '@workbench/canvas-engine/api'; +import type { + CommitRasterFilterOptions, + CommitRasterFilterResult, +} from '@workbench/canvas-engine/controllers/filterResultController'; +import type { + CommitMaskImageResult, + CommitMaskImageResultOptions, +} from '@workbench/canvas-engine/controllers/maskResultController'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { SamInteractionState, SamVisualInput } from '@workbench/canvas-engine/samInteraction'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasImageRef } from '@workbench/types'; + +export type SelectObjectStartContext = + | { status: 'missing' | 'disabled' | 'locked' | 'unsupported' | 'not-ready' } + | { + status: 'ready'; + guard: LayerExportGuard; + layerId: string; + layerName: string; + layerType: 'raster' | 'control'; + sourceRect: Rect; + }; + +/** Narrow core primitives used only by application-level canvas coordinators. */ +export interface CanvasApplicationHost { + captureGuard(layerId: string): LayerExportGuard | null; + clearFilterPreview(layerId: string): void; + clearSamPreview(): void; + commitFilter(options: CommitRasterFilterOptions): Promise; + commitGenerated(options: CommitGeneratedImageOptions): Promise; + commitMask(options: CommitMaskImageResultOptions): Promise; + decodeSelectObjectPreview(result: { image: CanvasImageRef; rect: Rect }, signal: AbortSignal): Promise; + encodeSurface(surface: RasterSurface): Promise; + exportBakedLayerBlob(layerId: string): Promise; + exportLayerPixels( + layerId: string, + options?: ExportLayerPixelsOptions + ): Promise< + | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + >; + getCompositeExecutorDeps(): CanvasCompositeExecutorDeps; + getDocument(): CanvasDocumentContractV2 | null; + isGuardCurrent(guard: LayerExportGuard): boolean; + isInteractionLocked(): boolean; + isSamToolActive(): boolean; + prepareSelectObjectStart(layerId: string): SelectObjectStartContext; + publishFilterPreview( + layerId: string, + imageName: string, + rect: Rect, + guard: LayerExportGuard, + filterType?: string + ): Promise<'shown' | 'missing' | 'stale'>; + publishSamPreview(preview: { + data: RasterSurface; + guard: LayerExportGuard; + isolated: boolean; + rect: Rect; + }): undefined; + replaceSelection( + guard: LayerExportGuard, + image: CanvasImageRef, + rect: Rect, + signal?: AbortSignal + ): Promise; + replaceTemporaryRestoreTool(): void; + selectLayer(layerId: string): void; + setSamInputHandler(handler: ((input: SamVisualInput) => void) | null): void; + setEscapeHandler(handler: ((gestureWasActive: boolean) => boolean) | null): void; + setSamInteraction(state: SamInteractionState | null): void; + setSamTool(): void; + setViewTool(): void; + subscribeToolChanges(listener: (change: { from: string; to: string; temporary: boolean }) => void): () => void; +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/browserRaster.browser.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/browserRaster.browser.test.ts new file mode 100644 index 00000000000..d21ac5a5a55 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/browserRaster.browser.test.ts @@ -0,0 +1,204 @@ +import type { Mat2d } from '@workbench/canvas-engine/types'; +import type { + CanvasDocumentContractV2, + CanvasLayerSourceContract, + CanvasRasterLayerContractV2, +} from '@workbench/types'; + +import { executePsdExport, planPsdExport } from '@workbench/canvas-engine/export/psdExport'; +import { createHistory } from '@workbench/canvas-engine/history/history'; +import { createImagePatchEntry } from '@workbench/canvas-engine/history/imagePatch'; +import { compositeDocument } from '@workbench/canvas-engine/render/compositor'; +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createDomRasterBackend, type RasterSurface } from '@workbench/canvas-engine/render/raster'; +import { rasterizeTextSource } from '@workbench/canvas-engine/render/rasterizers/textRasterizer'; +import { describe, expect, it } from 'vitest'; + +const IDENTITY: Mat2d = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + +const readPixel = (surface: RasterSurface, x: number, y: number): number[] => [ + ...surface.ctx.getImageData(x, y, 1, 1).data, +]; + +const expectPixel = (surface: RasterSurface, x: number, y: number, expected: number[], tolerance = 0): void => { + const actual = readPixel(surface, x, y); + expected.forEach((channel, index) => { + expect(actual[index]).toBeGreaterThanOrEqual(channel - tolerance); + expect(actual[index]).toBeLessThanOrEqual(channel + tolerance); + }); +}; + +const rasterLayer = ( + id: string, + overrides: Partial = {} +): CanvasRasterLayerContractV2 => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height: 8, imageName: id, width: 8 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...overrides, +}); + +const documentWith = (layers: CanvasRasterLayerContractV2[]): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 8, width: 8, x: 0, y: 0 }, + height: 8, + layers, + selectedLayerId: layers[0]?.id ?? null, + version: 2, + width: 8, +}); + +describe('real browser raster acceptance', () => { + it('composites transformed layers through clipping, alpha, and multiply blend mode', () => { + const backend = createDomRasterBackend(); + const caches = createLayerCacheStore(backend); + const bottom = caches.getOrCreate('bottom', 8, 8); + bottom.surface.ctx.fillStyle = '#ff0000'; + bottom.surface.ctx.fillRect(0, 0, 8, 8); + caches.publishPixels('bottom'); + + const top = caches.getOrCreate('top', 4, 8); + top.surface.ctx.fillStyle = '#0000ff'; + top.surface.ctx.fillRect(0, 0, 4, 8); + caches.publishPixels('top'); + + const target = backend.createSurface(8, 8); + compositeDocument( + target, + documentWith([ + rasterLayer('top', { + blendMode: 'multiply', + opacity: 0.5, + source: { image: { height: 8, imageName: 'top', width: 4 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 2, y: 0 }, + }), + rasterLayer('bottom'), + ]), + caches, + IDENTITY, + { clipRect: { height: 8, width: 4, x: 0, y: 0 } } + ); + + expectPixel(target, 1, 1, [255, 0, 0, 255]); + expectPixel(target, 3, 1, [127, 0, 0, 255], 2); + expectPixel(target, 5, 1, [0, 0, 0, 0]); + }); + + it('uses browser text metrics and produces real glyph pixels', async () => { + const backend = createDomRasterBackend(); + const source: Extract = { + align: 'left', + color: '#ff0000', + content: 'Invoke', + fontFamily: 'sans-serif', + fontSize: 24, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + }; + const measureSurface = backend.createSurface(1, 1); + measureSurface.ctx.font = '400 24px sans-serif'; + const browserWidth = Math.ceil(measureSurface.ctx.measureText(source.content).width); + + const result = await rasterizeTextSource(source, { + backend, + documentSize: { height: 64, width: 64 }, + resolver: () => Promise.resolve(new Blob()), + store: createLayerCacheStore(backend), + }); + const pixels = result.surface.ctx.getImageData(0, 0, result.surface.width, result.surface.height).data; + + expect(result.rect.width).toBe(browserWidth); + expect(result.rect.height).toBe(Math.ceil(24 * 1.2)); + expect(pixels.some((channel, index) => index % 4 === 3 && channel > 0)).toBe(true); + }); + + it('encodes a PNG blob and decodes it through createImageBitmap', async () => { + const backend = createDomRasterBackend(); + const source = backend.createSurface(3, 2); + source.ctx.fillStyle = '#00ff00'; + source.ctx.fillRect(0, 0, 3, 2); + + const blob = await backend.encodeSurface(source); + const bitmap = await backend.createImageBitmap(blob); + const decoded = backend.createSurface(3, 2); + decoded.ctx.drawImage(bitmap, 0, 0); + + expect(blob.type).toBe('image/png'); + expect(blob.size).toBeGreaterThan(0); + expect(bitmap.width).toBe(3); + expect(bitmap.height).toBe(2); + expectPixel(decoded, 1, 1, [0, 255, 0, 255]); + bitmap.close(); + }); + + it('undoes and redoes a real pixel patch', () => { + const backend = createDomRasterBackend(); + const surface = backend.createSurface(1, 1); + const before = new ImageData(new Uint8ClampedArray([255, 0, 0, 255]), 1, 1); + const after = new ImageData(new Uint8ClampedArray([0, 0, 255, 255]), 1, 1); + const history = createHistory(); + surface.ctx.putImageData(after, 0, 0); + history.push( + createImagePatchEntry({ + after, + apply: (_layerId, rect, pixels) => surface.ctx.putImageData(pixels, rect.x, rect.y), + before, + label: 'Browser pixel edit', + layerId: 'paint', + rect: { height: 1, width: 1, x: 0, y: 0 }, + }) + ); + + history.undo(); + expectPixel(surface, 0, 0, [255, 0, 0, 255]); + history.redo(); + expectPixel(surface, 0, 0, [0, 0, 255, 255]); + }); + + it('round-trips a small real PSD with layer and composite pixels', async () => { + const backend = createDomRasterBackend(); + const layerSurface = backend.createSurface(2, 2); + layerSurface.ctx.fillStyle = '#ff0000'; + layerSurface.ctx.fillRect(0, 0, 2, 2); + const plan = planPsdExport([ + { + blendMode: 'normal', + contentRect: { height: 2, width: 2, x: 0, y: 0 }, + id: 'red', + isEnabled: true, + name: 'Red', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + }, + ]); + let bytes: ArrayBuffer | null = null; + + await executePsdExport(plan, 'acceptance.psd', { + backend, + download: (data) => { + bytes = data; + }, + getLayerSurface: () => + Promise.resolve({ + rect: { height: 2, width: 2, x: 0, y: 0 }, + surface: layerSurface, + }), + }); + + expect(bytes).not.toBeNull(); + const { readPsd } = await import('ag-psd'); + const parsed = readPsd(bytes!, { useImageData: true }); + expect(parsed.width).toBe(2); + expect(parsed.height).toBe(2); + expect(parsed.children?.map((child) => child.name)).toEqual(['Red']); + expect(Array.from(parsed.imageData!.data.slice(0, 4))).toEqual([255, 0, 0, 255]); + expect(Array.from(parsed.children![0]!.imageData!.data.slice(0, 4))).toEqual([255, 0, 0, 255]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/color.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/color.test.ts new file mode 100644 index 00000000000..999eb3c030f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/color.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { rgbaToHex } from './color'; + +describe('rgbaToHex', () => { + it('formats channels as a lowercase #rrggbb string', () => { + expect(rgbaToHex(0, 0, 0)).toBe('#000000'); + expect(rgbaToHex(255, 255, 255)).toBe('#ffffff'); + expect(rgbaToHex(255, 0, 0)).toBe('#ff0000'); + expect(rgbaToHex(18, 52, 86)).toBe('#123456'); + }); + + it('pads single-digit hex bytes with a leading zero', () => { + expect(rgbaToHex(1, 2, 3)).toBe('#010203'); + }); + + it('rounds fractional channels to the nearest integer', () => { + expect(rgbaToHex(127.6, 127.4, 0)).toBe('#807f00'); + }); + + it('clamps out-of-range channels to [0, 255]', () => { + expect(rgbaToHex(-10, 300, 128)).toBe('#00ff80'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/color.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/color.ts new file mode 100644 index 00000000000..8ddef1befc8 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/color.ts @@ -0,0 +1,18 @@ +/** + * Small pure color helpers shared by the engine. Zero React, zero import-time + * side effects. + */ + +/** Clamps a color channel to the `[0, 255]` byte range, rounding to the nearest integer. */ +const clampChannel = (value: number): number => Math.max(0, Math.min(255, Math.round(value))); + +const toHexByte = (value: number): string => clampChannel(value).toString(16).padStart(2, '0'); + +/** + * Formats an opaque color as a lowercase `#rrggbb` CSS hex string. Channels are + * `[0, 255]`; alpha is intentionally not part of the output — brush/eraser + * options store an opaque fill color, and the color picker's own transparency + * check (`sampleDocumentColor` returning `null`) already decides whether a + * sample is pickable at all. + */ +export const rgbaToHex = (r: number, g: number, b: number): string => `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/booleanMergeController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/booleanMergeController.ts new file mode 100644 index 00000000000..19ce862c53f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/booleanMergeController.ts @@ -0,0 +1,212 @@ +import type { LayerExportGuard } from '@workbench/canvas-engine/api'; +import type { History } from '@workbench/canvas-engine/history/history'; +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { isMergeableRasterLayer } from '@workbench/canvas-engine/document/sources'; +import { isEmpty, roundOut, union } from '@workbench/canvas-engine/math/rect'; + +export type BooleanRasterOperation = 'intersect' | 'cutout' | 'cutaway' | 'exclude'; +export type BooleanRasterResult = 'merged' | 'missing' | 'unsupported' | 'not-ready' | 'busy' | 'empty'; + +type ExportResult = + | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' }; + +export interface BooleanMergeControllerOptions { + readonly backend: RasterBackend; + readonly history: History; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly getReducerDocument: () => CanvasDocumentContractV2 | null; + readonly capturePermit: () => object | null; + readonly isPermitCurrent: (permit: object) => boolean; + readonly isGestureActive: () => boolean; + readonly endBurst: () => void; + readonly isCacheReady: (layer: CanvasLayerContract, document: CanvasDocumentContractV2) => boolean; + readonly exportBaked: (layerId: string) => Promise; + readonly isGuardCurrent: (guard: LayerExportGuard) => boolean; + readonly createLayerId: () => string; + readonly dispatchPrepared: ( + action: CanvasProjectMutation, + expectedReducer: () => boolean, + expectedMirror: () => boolean + ) => void; + readonly preparePixels: (layerId: string, rect: Rect, pixels: RasterSurface) => unknown; + readonly installPrepared: (prepared: unknown) => void; +} + +const modes: Record = { + cutaway: 'source-out', + cutout: 'destination-in', + exclude: 'xor', + intersect: 'source-in', +}; + +/** Owns guarded two-layer boolean compositing and atomic stack history. */ +export class BooleanMergeController { + private disposed = false; + + constructor(private readonly deps: BooleanMergeControllerOptions) {} + + async merge(upperLayerId: string, operation: BooleanRasterOperation): Promise { + const permit = this.deps.capturePermit(); + if (this.disposed || !permit || this.deps.isGestureActive()) { + return 'busy'; + } + this.deps.endBurst(); + const document = this.deps.getDocument(); + if (!document) { + return 'missing'; + } + const upperIndex = document.layers.findIndex((layer) => layer.id === upperLayerId); + const upper = document.layers[upperIndex]; + const below = document.layers[upperIndex + 1]; + if (upperIndex < 0 || !upper || !below) { + return 'missing'; + } + if (!isMergeableRasterLayer(upper) || !isMergeableRasterLayer(below)) { + return 'unsupported'; + } + if (!this.deps.isCacheReady(upper, document) || !this.deps.isCacheReady(below, document)) { + return 'not-ready'; + } + const owned: Extract[] = []; + const acquire = async (layerId: string): Promise => { + const result = await this.deps.exportBaked(layerId); + if (result.status === 'ok') { + owned.push(result); + } + return result; + }; + try { + const settled = await Promise.allSettled([acquire(upper.id), acquire(below.id)]); + const rejected = settled.find((result) => result.status === 'rejected'); + if (rejected?.status === 'rejected') { + throw rejected.reason instanceof Error ? rejected.reason : new Error(String(rejected.reason)); + } + const [upperPixels, belowPixels] = settled.map( + (result) => (result as PromiseFulfilledResult).value + ); + if (!this.deps.isPermitCurrent(permit)) { + return 'busy'; + } + if (upperPixels.status !== 'ok' || belowPixels.status !== 'ok') { + if (upperPixels.status === 'not-ready' || belowPixels.status === 'not-ready') { + return 'not-ready'; + } + if ( + upperPixels.status === 'disabled' || + upperPixels.status === 'unsupported' || + belowPixels.status === 'disabled' || + belowPixels.status === 'unsupported' + ) { + return 'unsupported'; + } + return 'empty'; + } + if ( + !this.deps.isPermitCurrent(permit) || + this.deps.isGestureActive() || + upperPixels.guard.layer !== upper || + belowPixels.guard.layer !== below || + !this.deps.isGuardCurrent(upperPixels.guard) || + !this.deps.isGuardCurrent(belowPixels.guard) + ) { + return this.deps.isPermitCurrent(permit) ? 'not-ready' : 'busy'; + } + const liveDocument = this.deps.getDocument(); + const liveIndex = liveDocument?.layers.findIndex((layer) => layer.id === upperLayerId) ?? -1; + if (!liveDocument || liveDocument.layers[liveIndex] !== upper || liveDocument.layers[liveIndex + 1] !== below) { + return 'not-ready'; + } + const resultRect = roundOut(union(upperPixels.rect, belowPixels.rect)); + if (isEmpty(resultRect)) { + return 'empty'; + } + const pixels = this.deps.backend.createSurface(resultRect.width, resultRect.height); + pixels.ctx.setTransform(1, 0, 0, 1, 0, 0); + pixels.ctx.clearRect(0, 0, resultRect.width, resultRect.height); + pixels.ctx.globalAlpha = below.opacity; + pixels.ctx.globalCompositeOperation = 'source-over'; + pixels.ctx.drawImage( + belowPixels.surface.canvas, + belowPixels.rect.x - resultRect.x, + belowPixels.rect.y - resultRect.y + ); + pixels.ctx.globalAlpha = upper.opacity; + pixels.ctx.globalCompositeOperation = modes[operation]; + pixels.ctx.drawImage( + upperPixels.surface.canvas, + upperPixels.rect.x - resultRect.x, + upperPixels.rect.y - resultRect.y + ); + const resultId = this.deps.createLayerId(); + const resultLayer: CanvasLayerContract = { + blendMode: 'normal', + id: resultId, + isEnabled: true, + isLocked: false, + name: `${upper.name} ${operation}`, + opacity: 1, + source: { bitmap: null, offset: { x: resultRect.x, y: resultRect.y }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }; + const original = [ + { id: upper.id, isEnabled: upper.isEnabled }, + { id: below.id, isEnabled: below.isEnabled }, + ]; + const disabled = original.map(({ id }) => ({ id, isEnabled: false })); + const selectedLayerId = liveDocument.selectedLayerId; + const hasState = (doc: CanvasDocumentContractV2 | null, updates: typeof original): boolean => + updates.every((update) => doc?.layers.find((layer) => layer.id === update.id)?.isEnabled === update.isEnabled); + const apply = (): void => { + const prepared = this.deps.preparePixels(resultId, resultRect, pixels); + this.deps.dispatchPrepared( + { + add: { index: liveIndex, layers: [resultLayer] }, + enabledUpdates: disabled, + selectedLayerId: resultId, + type: 'applyCanvasLayerStackMutation', + }, + () => + this.deps.getReducerDocument()?.selectedLayerId === resultId && + hasState(this.deps.getReducerDocument(), disabled), + () => this.deps.getDocument()?.selectedLayerId === resultId && hasState(this.deps.getDocument(), disabled) + ); + this.deps.installPrepared(prepared); + }; + if (!this.deps.isPermitCurrent(permit)) { + return 'busy'; + } + apply(); + this.deps.history.push({ + bytes: resultRect.width * resultRect.height * 4 + 256, + label: `Boolean ${operation}`, + redo: apply, + replayFailureAtomic: true, + undo: () => + this.deps.dispatchPrepared( + { enabledUpdates: original, removeIds: [resultId], selectedLayerId, type: 'applyCanvasLayerStackMutation' }, + () => + this.deps.getReducerDocument()?.selectedLayerId === selectedLayerId && + hasState(this.deps.getReducerDocument(), original), + () => + this.deps.getDocument()?.selectedLayerId === selectedLayerId && + hasState(this.deps.getDocument(), original) + ), + }); + return 'merged'; + } finally { + for (const result of owned) { + result.release(); + } + } + } + + dispose(): void { + this.disposed = true; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/controlPixelController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/controlPixelController.test.ts new file mode 100644 index 00000000000..63ab0c6ac80 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/controlPixelController.test.ts @@ -0,0 +1,36 @@ +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it, vi } from 'vitest'; + +import { ControlPixelController } from './controlPixelController'; + +describe('ControlPixelController', () => { + it('can be instantiated with narrow fakes and rejects edits without a document', () => { + const controller = new ControlPixelController({ + applyImagePatch: vi.fn(), + backend: createTestStubRasterBackend(), + bitmapStore: { discardLayer: vi.fn(), markLayerDirty: vi.fn(), suspendLayer: vi.fn() } as never, + canEdit: () => true, + deleteDerived: vi.fn(), + dispatchReplacement: vi.fn(), + endBurst: vi.fn(), + getActiveProjectId: () => 'project-1', + getDocument: () => null, + getTransformSession: () => null, + history: {} as never, + installPrepared: vi.fn(), + invalidate: vi.fn(), + isCacheReady: () => false, + isOperationIdle: () => true, + layers: {} as never, + notifyPainted: vi.fn(), + preparePixels: vi.fn(), + projectId: 'project-1', + publishStroke: vi.fn(), + setTransformOverride: vi.fn(), + }); + + expect(controller.begin('control-1')).toBeNull(); + expect(controller.isOpenFor(['control-1'])).toBe(false); + expect(() => controller.dispose()).not.toThrow(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/controlPixelController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/controlPixelController.ts new file mode 100644 index 00000000000..329da8ee448 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/controlPixelController.ts @@ -0,0 +1,439 @@ +import type { BitmapStore } from '@workbench/canvas-engine/document/bitmapStore'; +import type { History } from '@workbench/canvas-engine/history/history'; +import type { ImagePatchApply } from '@workbench/canvas-engine/history/imagePatch'; +import type { LayerPixelSnapshot, LayerPixelSnapshotApply } from '@workbench/canvas-engine/history/layerSnapshot'; +import type { LayerCacheStore, PreparedLayerCacheReplacement } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterBackend } from '@workbench/canvas-engine/render/raster'; +import type { + ControlPixelEditTransaction, + PixelEditPatch, + StrokeCommittedEvent, +} from '@workbench/canvas-engine/tools/tool'; +import type { LayerTransform } from '@workbench/canvas-engine/transform/transformMath'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasControlLayerContract, CanvasDocumentContractV2 } from '@workbench/types'; + +import { getSourceContentRect } from '@workbench/canvas-engine/document/sources'; +import { + bakeControlPixelEditSurface, + buildMaterializedControlLayer, + decideControlPixelEdit, +} from '@workbench/canvas-engine/editing/controlPixelEdit'; +import { createImagePatchEntry } from '@workbench/canvas-engine/history/imagePatch'; +import { createLayerSnapshotEntry } from '@workbench/canvas-engine/history/layerSnapshot'; +import { isEmpty } from '@workbench/canvas-engine/math/rect'; + +export interface ControlPixelControllerOptions { + readonly applyImagePatch: ImagePatchApply; + readonly backend: RasterBackend; + readonly bitmapStore: Pick; + readonly canEdit: () => boolean; + readonly deleteDerived: (layerId: string) => void; + readonly dispatchReplacement: (layer: CanvasControlLayerContract) => void; + readonly endBurst: () => void; + readonly getActiveProjectId: () => string | null; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly getTransformSession: () => unknown; + readonly history: History; + readonly installPrepared: (prepared: PreparedLayerCacheReplacement, persist?: boolean) => void; + readonly invalidate: (layerId: string, overlay?: boolean) => void; + readonly isCacheReady: (layer: CanvasControlLayerContract, document: CanvasDocumentContractV2) => boolean; + readonly isOperationIdle: () => boolean; + readonly layers: LayerCacheStore; + readonly notifyPainted: (layerId: string) => void; + readonly preparePixels: ( + layerId: string, + rect: Rect, + pixels: ReturnType + ) => PreparedLayerCacheReplacement; + readonly projectId: string; + readonly publishStroke: (event: StrokeCommittedEvent) => void; + readonly setTransformOverride: (layerId: string, transform: LayerTransform | null) => void; +} + +const isImageDataEqual = (left: ImageData, right: ImageData): boolean => { + if (left.width !== right.width || left.height !== right.height || left.data.length !== right.data.length) { + return false; + } + for (let index = 0; index < left.data.length; index += 1) { + if (left.data[index] !== right.data[index]) { + return false; + } + } + return true; +}; + +/** Owns the exclusive direct/materialized control-layer pixel transaction. */ +export class ControlPixelController { + private open: { cancel: () => void; layerId: string } | null = null; + + constructor(private readonly options: ControlPixelControllerOptions) {} + + cancel(): void { + this.open?.cancel(); + } + + isOpenFor(layerIds: readonly string[]): boolean { + return this.open !== null && layerIds.includes(this.open.layerId); + } + + private applySnapshot: LayerPixelSnapshotApply = (snapshot) => { + const pixels = this.options.backend.createSurface(snapshot.rect.width, snapshot.rect.height); + if (snapshot.pixels) { + pixels.ctx.putImageData(snapshot.pixels, 0, 0); + } + const prepared = this.options.preparePixels(snapshot.layer.id, snapshot.rect, pixels); + this.options.dispatchReplacement(snapshot.layer); + try { + this.options.bitmapStore.discardLayer(snapshot.layer.id); + } catch { + // Persistence bookkeeping is ancillary after reducer acceptance. + } + this.options.installPrepared(prepared, snapshot.layer.source.type === 'paint'); + }; + + begin(layerId: string): ControlPixelEditTransaction | null { + const o = this.options; + const document = o.getDocument(); + const layer = document?.layers.find((candidate) => candidate.id === layerId); + if ( + !o.canEdit() || + !document || + document.selectedLayerId !== layerId || + !layer || + layer.type !== 'control' || + this.open || + !o.isOperationIdle() || + o.getTransformSession() + ) { + return null; + } + const contentRect = getSourceContentRect(layer, document); + const decision = decideControlPixelEdit({ + hasSourceContent: !isEmpty(contentRect), + isCacheReady: o.isCacheReady(layer, document), + layer, + }); + if (decision.status === 'rejected') { + return null; + } + if (decision.status === 'direct') { + return this.beginDirect(layerId, layer); + } + return this.beginMaterialized(layerId, layer, contentRect); + } + + private beginDirect(layerId: string, layer: CanvasControlLayerContract): ControlPixelEditTransaction | null { + const o = this.options; + const originalEntry = o.layers.get(layerId); + let originalPixels: ImageData | null = null; + if (originalEntry && !isEmpty(originalEntry.rect)) { + try { + originalPixels = originalEntry.surface.ctx.getImageData( + 0, + 0, + originalEntry.rect.width, + originalEntry.rect.height + ); + } catch { + return null; + } + } + const original = originalEntry + ? { + hasPublishedPixels: originalEntry.hasPublishedPixels, + lastUsed: originalEntry.lastUsed, + pixels: originalPixels, + rect: { ...originalEntry.rect }, + stale: originalEntry.stale, + surface: originalEntry.surface, + version: originalEntry.version, + } + : null; + const releasePersistence = o.bitmapStore.suspendLayer(layerId); + let closed = false; + let owner: { cancel: () => void; layerId: string }; + const close = (): boolean => { + if (closed || this.open !== owner) { + return false; + } + closed = true; + this.open = null; + return true; + }; + const restore = (): void => { + try { + if (!original) { + o.layers.delete(layerId); + } else { + original.surface.resize(original.rect.width, original.rect.height); + if (original.pixels) { + original.surface.ctx.putImageData(original.pixels, 0, 0); + } + const current = o.layers.get(layerId) ?? o.layers.getOrCreateRect(layerId, original.rect); + Object.assign(current, { + hasPublishedPixels: original.hasPublishedPixels, + lastUsed: original.lastUsed, + rect: { ...original.rect }, + stale: original.stale, + surface: original.surface, + version: original.version, + }); + } + } finally { + o.deleteDerived(layerId); + o.invalidate(layerId); + } + }; + const restoreAndRelease = (): void => { + try { + restore(); + } finally { + releasePersistence(); + } + }; + const cancel = (): void => { + if (close()) { + restoreAndRelease(); + } + }; + const commitPatch = (label: string, patch: PixelEditPatch): boolean => { + if (closed || this.open !== owner) { + return false; + } + if (isImageDataEqual(patch.before, patch.after)) { + cancel(); + return false; + } + const document = o.getDocument(); + if ( + o.getActiveProjectId() !== o.projectId || + !o.canEdit() || + !o.isOperationIdle() || + document?.selectedLayerId !== layerId || + document.layers.find((candidate) => candidate.id === layerId) !== layer + ) { + cancel(); + return false; + } + close(); + let entry = null; + try { + if (!o.history.isApplying()) { + entry = createImagePatchEntry({ + after: patch.after, + apply: o.applyImagePatch, + before: patch.before, + label, + layerId, + rect: patch.rect, + }); + } + } catch (error) { + restoreAndRelease(); + throw error; + } + o.endBurst(); + try { + if (entry) { + o.history.push(entry); + } + o.notifyPainted(layerId); + o.bitmapStore.markLayerDirty(layerId); + } finally { + releasePersistence(); + } + return true; + }; + const transaction: ControlPixelEditTransaction = { + cancel, + commitPatch: (label, patch) => void commitPatch(label, patch), + commitStroke: (event) => { + if (event.layerId !== layerId) { + cancel(); + return; + } + if ( + commitPatch(event.tool === 'eraser' ? 'Eraser stroke' : 'Brush stroke', { + after: event.afterImageData, + before: event.beforeImageData, + rect: event.dirtyRect, + }) + ) { + o.publishStroke(event); + } + }, + layerId, + }; + owner = { cancel, layerId }; + this.open = owner; + return transaction; + } + + private beginMaterialized( + layerId: string, + layer: CanvasControlLayerContract, + contentRect: Rect + ): ControlPixelEditTransaction | null { + const o = this.options; + const originalEntry = o.layers.get(layerId); + const original = originalEntry + ? { + hasPublishedPixels: originalEntry.hasPublishedPixels, + lastUsed: originalEntry.lastUsed, + rect: { ...originalEntry.rect }, + stale: originalEntry.stale, + surface: originalEntry.surface, + version: originalEntry.version, + } + : null; + const beforeRect = original?.rect ?? { ...contentRect }; + let beforePixels: ImageData | null = null; + if (!isEmpty(beforeRect)) { + if (!originalEntry) { + return null; + } + try { + beforePixels = originalEntry.surface.ctx.getImageData(0, 0, beforeRect.width, beforeRect.height); + } catch { + return null; + } + } + let prepared: PreparedLayerCacheReplacement; + try { + if (originalEntry && !isEmpty(originalEntry.rect)) { + const baked = bakeControlPixelEditSurface({ + backend: o.backend, + source: originalEntry.surface, + sourceRect: originalEntry.rect, + transform: layer.transform, + }); + prepared = o.preparePixels(layerId, baked.rect, baked.surface); + } else { + prepared = o.preparePixels(layerId, { height: 0, width: 0, x: 0, y: 0 }, o.backend.createSurface(0, 0)); + } + } catch { + return null; + } + const before: LayerPixelSnapshot = { layer: structuredClone(layer), pixels: beforePixels, rect: beforeRect }; + const restore = (): void => { + try { + if (original) { + const current = o.layers.get(layerId); + if (current) { + Object.assign(current, { ...original, rect: { ...original.rect } }); + } + } else { + o.layers.delete(layerId); + } + } finally { + o.deleteDerived(layerId); + o.setTransformOverride(layerId, null); + o.invalidate(layerId, true); + } + }; + const releasePersistence = o.bitmapStore.suspendLayer(layerId); + const restoreAndRelease = (): void => { + try { + restore(); + } finally { + releasePersistence(); + } + }; + try { + const preview = originalEntry ?? o.layers.getOrCreateRect(layerId, prepared.rect); + Object.assign(preview, { + surface: prepared.surface, + rect: { ...prepared.rect }, + hasPublishedPixels: true, + stale: false, + }); + preview.version += 1; + o.deleteDerived(layerId); + o.setTransformOverride(layerId, { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }); + o.invalidate(layerId, true); + } catch { + restoreAndRelease(); + return null; + } + let closed = false; + let owner: { cancel: () => void; layerId: string }; + const close = (): boolean => { + if (closed || this.open !== owner) { + return false; + } + closed = true; + this.open = null; + return true; + }; + const cancel = (): void => { + if (close()) { + restoreAndRelease(); + } + }; + const commit = (label: string, event?: StrokeCommittedEvent): void => { + if (!close()) { + return; + } + const document = o.getDocument(); + const edited = o.layers.get(layerId); + if ( + o.getActiveProjectId() !== o.projectId || + !o.canEdit() || + !o.isOperationIdle() || + document?.selectedLayerId !== layerId || + document.layers.find((candidate) => candidate.id === layerId) !== layer || + !edited || + (event && event.layerId !== layerId) + ) { + restoreAndRelease(); + return; + } + let entry = null; + try { + const pixels = isEmpty(edited.rect) + ? null + : edited.surface.ctx.getImageData(0, 0, edited.rect.width, edited.rect.height); + const materialized = buildMaterializedControlLayer(layer, edited.rect); + const after: LayerPixelSnapshot = { layer: materialized, pixels, rect: { ...edited.rect } }; + if (!o.history.isApplying()) { + entry = createLayerSnapshotEntry({ after, apply: this.applySnapshot, before, label }); + } + o.dispatchReplacement(materialized); + } catch (error) { + restoreAndRelease(); + throw error; + } + o.setTransformOverride(layerId, null); + o.endBurst(); + try { + if (entry) { + o.history.push(entry); + } + o.notifyPainted(layerId); + o.bitmapStore.markLayerDirty(layerId); + } finally { + releasePersistence(); + } + if (event) { + o.publishStroke(event); + } + }; + const transaction: ControlPixelEditTransaction = { + cancel, + commitPatch: (label, patch) => (isImageDataEqual(patch.before, patch.after) ? cancel() : commit(label)), + commitStroke: (event) => + isImageDataEqual(event.beforeImageData, event.afterImageData) + ? cancel() + : commit(event.tool === 'eraser' ? 'Eraser stroke' : 'Brush stroke', event), + layerId, + }; + owner = { cancel, layerId }; + this.open = owner; + return transaction; + } + + dispose(): void { + this.cancel(); + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/copyLayerController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/copyLayerController.ts new file mode 100644 index 00000000000..1601911e9ea --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/copyLayerController.ts @@ -0,0 +1,124 @@ +import type { LayerExportGuard } from '@workbench/canvas-engine/api'; +import type { History } from '@workbench/canvas-engine/history/history'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +type ExportResult = + | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' }; + +export interface CopyLayerControllerOptions { + readonly history: History; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly getReducerDocument: () => CanvasDocumentContractV2 | null; + readonly capturePermit: () => object | null; + readonly isPermitCurrent: (permit: object) => boolean; + readonly isGestureActive: () => boolean; + readonly endBurst: () => void; + readonly exportBaked: (layerId: string) => Promise; + readonly isGuardCurrent: (guard: LayerExportGuard) => boolean; + readonly createLayerId: () => string; + readonly preparePixels: (layerId: string, rect: Rect, pixels: RasterSurface) => unknown; + readonly installPrepared: (prepared: unknown) => void; + readonly dispatchPrepared: ( + action: CanvasProjectMutation, + expectedReducer: () => boolean, + expectedMirror: () => boolean + ) => void; +} + +/** Owns guarded baked copies into new raster paint layers. */ +export class CopyLayerController { + private disposed = false; + constructor(private readonly deps: CopyLayerControllerOptions) {} + + async copyToRaster(layerId: string): Promise { + const permit = this.deps.capturePermit(); + if (this.disposed || !permit || this.deps.isGestureActive()) { + return null; + } + this.deps.endBurst(); + const document = this.deps.getDocument(); + const sourceLayer = document?.layers.find((candidate) => candidate.id === layerId); + if (!document || !sourceLayer) { + return null; + } + const baked = await this.deps.exportBaked(layerId); + if (baked.status !== 'ok') { + return null; + } + try { + if (!this.deps.isPermitCurrent(permit)) { + return null; + } + if (this.deps.isGestureActive() || !this.deps.isGuardCurrent(baked.guard) || baked.guard.layer !== sourceLayer) { + return null; + } + const liveDocument = this.deps.getDocument(); + const sourceIndex = liveDocument?.layers.findIndex((layer) => layer.id === layerId) ?? -1; + if (!liveDocument || liveDocument.layers[sourceIndex] !== sourceLayer || sourceIndex < 0) { + return null; + } + const newId = this.deps.createLayerId(); + const layer: CanvasLayerContract = { + blendMode: 'normal', + id: newId, + isEnabled: true, + isLocked: false, + name: `${sourceLayer.name} copy`, + opacity: 1, + source: { bitmap: null, offset: { x: baked.rect.x, y: baked.rect.y }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }; + const selectedLayerId = liveDocument.selectedLayerId; + const apply = (): void => { + const prepared = this.deps.preparePixels(newId, baked.rect, baked.surface); + this.deps.dispatchPrepared( + { + add: { index: sourceIndex, layers: [layer] }, + enabledUpdates: [], + selectedLayerId: newId, + type: 'applyCanvasLayerStackMutation', + }, + () => + this.deps.getReducerDocument()?.selectedLayerId === newId && + this.deps.getReducerDocument()?.layers.some((candidate) => candidate === layer) === true, + () => + this.deps.getDocument()?.selectedLayerId === newId && + this.deps.getDocument()?.layers.some((candidate) => candidate === layer) === true + ); + this.deps.installPrepared(prepared); + }; + if (!this.deps.isPermitCurrent(permit)) { + return null; + } + apply(); + this.deps.history.push({ + bytes: baked.rect.width * baked.rect.height * 4 + 256, + label: 'Copy layer to raster', + redo: apply, + replayFailureAtomic: true, + undo: () => + this.deps.dispatchPrepared( + { enabledUpdates: [], removeIds: [newId], selectedLayerId, type: 'applyCanvasLayerStackMutation' }, + () => + this.deps.getReducerDocument()?.selectedLayerId === selectedLayerId && + this.deps.getReducerDocument()?.layers.some((candidate) => candidate.id === newId) === false, + () => + this.deps.getDocument()?.selectedLayerId === selectedLayerId && + this.deps.getDocument()?.layers.some((candidate) => candidate.id === newId) === false + ), + }); + return newId; + } finally { + baked.release(); + } + } + + dispose(): void { + this.disposed = true; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.ts new file mode 100644 index 00000000000..3f22fc11e5f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.ts @@ -0,0 +1,167 @@ +import type { LayerExportGuard } from '@workbench/canvas-engine/api'; +import type { History } from '@workbench/canvas-engine/history/history'; +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasLayerContract, CanvasLayerSourceContract } from '@workbench/types'; + +import { renderableSourceOf } from '@workbench/canvas-engine/document/sources'; +import { intersect, isEmpty, roundOut } from '@workbench/canvas-engine/math/rect'; + +export type CropLayerResult = + | { status: 'cropped' } + | { status: 'missing' | 'locked' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' | 'busy' } + | { status: 'failed'; message: string }; + +type ExportResult = + | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' }; + +interface PixelSnapshot { + pixels: RasterSurface; + rect: Rect; +} + +export interface CropLayerControllerOptions { + readonly backend: RasterBackend; + readonly history: History; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly getReducerDocument: () => CanvasDocumentContractV2 | null; + readonly capturePermit: () => object | null; + readonly isPermitCurrent: (permit: object) => boolean; + readonly isGestureActive: () => boolean; + readonly endBurst: () => void; + readonly isSupportedSource: (source: CanvasLayerSourceContract) => boolean; + readonly exportBaked: (layerId: string) => Promise; + readonly isGuardCurrent: (guard: LayerExportGuard) => boolean; + readonly captureCache: ( + layer: CanvasLayerContract, + document: CanvasDocumentContractV2 + ) => PixelSnapshot | null | 'not-ready'; + readonly preparePixels: (layerId: string, rect: Rect, pixels: RasterSurface) => unknown; + readonly installPrepared: (prepared: unknown) => void; + readonly discardPersisted: (layerId: string) => void; + readonly dispatchPrepared: ( + action: CanvasProjectMutation, + expectedReducer: () => boolean, + expectedMirror: () => boolean + ) => void; +} + +/** Owns guarded crop-to-bbox conversion and replayable pixel snapshots. */ +export class CropLayerController { + private disposed = false; + constructor(private readonly deps: CropLayerControllerOptions) {} + + async crop(layerId: string): Promise { + const permit = this.deps.capturePermit(); + if (this.disposed || !permit || this.deps.isGestureActive()) { + return { status: 'busy' }; + } + this.deps.endBurst(); + const document = this.deps.getDocument(); + const layer = document?.layers.find((candidate) => candidate.id === layerId); + if (!document || !layer) { + return { status: 'missing' }; + } + if (layer.isLocked) { + return { status: 'locked' }; + } + const source = renderableSourceOf(layer); + if (!source || !this.deps.isSupportedSource(source)) { + return { status: 'unsupported' }; + } + try { + const exported = await this.deps.exportBaked(layerId); + if (exported.status !== 'ok') { + return { status: exported.status === 'disabled' ? 'not-ready' : exported.status }; + } + try { + if (!this.deps.isPermitCurrent(permit)) { + return { status: 'busy' }; + } + const liveDocument = this.deps.getDocument(); + const liveLayer = liveDocument?.layers.find((candidate) => candidate.id === layerId); + if (!liveDocument || !liveLayer) { + return { status: 'missing' }; + } + if (!this.deps.isPermitCurrent(permit) || this.deps.isGestureActive()) { + return { status: 'busy' }; + } + if (liveLayer.isLocked) { + return { status: 'locked' }; + } + if (!this.deps.isGuardCurrent(exported.guard)) { + return { status: 'not-ready' }; + } + const liveSource = renderableSourceOf(liveLayer); + if (!liveSource || !this.deps.isSupportedSource(liveSource)) { + return { status: 'unsupported' }; + } + const overlap = intersect(exported.rect, roundOut(liveDocument.bbox)); + if (!overlap || isEmpty(overlap)) { + return { status: 'empty' }; + } + const cropRect = roundOut(overlap); + const beforePixels = this.deps.captureCache(liveLayer, liveDocument); + if (!beforePixels || beforePixels === 'not-ready') { + return { status: 'not-ready' }; + } + const before = structuredClone(liveLayer); + const cropped = this.deps.backend.createSurface(cropRect.width, cropRect.height); + cropped.ctx.setTransform(1, 0, 0, 1, 0, 0); + cropped.ctx.clearRect(0, 0, cropRect.width, cropRect.height); + cropped.ctx.drawImage(exported.surface.canvas, exported.rect.x - cropRect.x, exported.rect.y - cropRect.y); + const identity = { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }; + const paint = { bitmap: null, offset: { x: cropRect.x, y: cropRect.y }, type: 'paint' } as const; + let after: CanvasLayerContract; + if (before.type === 'raster') { + const { adjustments: _adjustments, ...rest } = before; + after = { ...rest, source: paint, transform: identity }; + } else if (before.type === 'control') { + const { filter: _filter, ...rest } = before; + after = { ...rest, source: paint, transform: identity }; + } else { + after = { ...before, mask: { ...before.mask, bitmap: null, offset: paint.offset }, transform: identity }; + } + const publish = (contract: CanvasLayerContract, prepared: unknown): void => { + this.deps.dispatchPrepared( + { layer: contract, layerId, type: 'replaceCanvasLayer' }, + () => this.deps.getReducerDocument()?.layers.find((candidate) => candidate.id === layerId) === contract, + () => this.deps.getDocument()?.layers.find((candidate) => candidate.id === layerId) === contract + ); + try { + this.deps.discardPersisted(layerId); + } catch { + /* ancillary */ + } + this.deps.installPrepared(prepared); + }; + const apply = (contract: CanvasLayerContract, snapshot: PixelSnapshot): void => + publish(contract, this.deps.preparePixels(layerId, snapshot.rect, snapshot.pixels)); + const afterPixels = { pixels: cropped, rect: cropRect }; + const prepared = this.deps.preparePixels(layerId, cropRect, cropped); + if (!this.deps.isPermitCurrent(permit)) { + return { status: 'busy' }; + } + publish(after, prepared); + this.deps.history.push({ + bytes: beforePixels.rect.width * beforePixels.rect.height * 4 + cropRect.width * cropRect.height * 4 + 256, + label: 'Crop layer to bbox', + redo: () => apply(after, afterPixels), + replayFailureAtomic: true, + undo: () => apply(before, beforePixels), + }); + return { status: 'cropped' }; + } finally { + exported.release(); + } + } catch (error) { + return { message: error instanceof Error ? error.message : String(error), status: 'failed' }; + } + } + + dispose(): void { + this.disposed = true; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editingController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editingController.test.ts new file mode 100644 index 00000000000..5d48d6db22c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editingController.test.ts @@ -0,0 +1,119 @@ +import type { SelectionState, SelectionStateDeps } from '@workbench/canvas-engine/selection/selectionState'; + +import { describe, expect, it, vi } from 'vitest'; + +import { EditingController } from './editingController'; + +const createSelection = (): SelectionState => ({ + antsPaths: () => [], + bounds: () => null, + clear: vi.fn(), + commit: vi.fn(), + containsPoint: () => false, + dispose: vi.fn(), + hasSelection: () => false, + invert: vi.fn(), + mask: () => null, + replaceMask: vi.fn(), + selectAll: vi.fn(), +}); + +const createTextOptions = () => ({ + canEdit: () => true, + commitStructural: vi.fn(), + createLayerId: () => 'text-1', + getDocument: () => null, + invalidate: vi.fn(), + isGestureActive: () => false, + options: { get: () => ({}) as never }, + session: { get: () => null, set: vi.fn() }, +}); + +const createTransformOptions = () => ({ + backend: {} as never, + canEdit: () => true, + dispatch: vi.fn(), + endBurst: vi.fn(), + getCache: () => null, + getDocument: () => null, + invalidate: vi.fn(), + isGestureActive: () => false, + pushHistory: vi.fn(), + replaceCache: vi.fn(), + restoreCache: vi.fn(), + session: { get: () => null, set: vi.fn() }, + setOverride: vi.fn(), +}); + +const createSelectionPixelOptions = () => ({ + applyImagePatch: vi.fn(), + backend: {} as never, + beginControlEdit: () => null, + canEdit: () => true, + deleteDerived: vi.fn(), + endBurst: vi.fn(), + getDocument: () => null, + getFillColor: () => '#000', + history: {} as never, + invalidateLayer: vi.fn(), + isGestureActive: () => false, + layers: {} as never, + markDirty: vi.fn(), + notifyPainted: vi.fn(), +}); + +const createSelectionImageOptions = () => ({ + capturePermit: () => null, + decodeImage: vi.fn(), + getDocument: () => null, + isGestureActive: () => false, + isGuardCurrent: () => false, + isPermitCurrent: () => false, +}); + +describe('EditingController', () => { + it('owns selection state and invalidates exclusive leases with document lifecycle', () => { + const selection = createSelection(); + const createSelectionState = vi.fn((_deps: SelectionStateDeps) => selection); + const controller = new EditingController({ + getDocument: () => null, + selection: {} as SelectionStateDeps, + selectionPixels: createSelectionPixelOptions(), + selectionImage: createSelectionImageOptions(), + createSelectionState, + text: createTextOptions(), + transform: createTransformOptions(), + }); + + expect(controller.selection).toBe(selection); + const lease = controller.edits.tryAcquire({ kind: 'filter', layerId: 'layer-1' }); + expect(lease?.isCurrent()).toBe(true); + + controller.invalidateDocument(); + expect(lease?.signal.aborted).toBe(true); + expect(lease?.isCurrent()).toBe(false); + expect(controller.edits.tryAcquire({ kind: 'filter' })?.isCurrent()).toBe(true); + }); + + it('disposes selection and leases idempotently and cannot reactivate afterward', () => { + const selection = createSelection(); + const controller = new EditingController({ + getDocument: () => null, + selection: {} as SelectionStateDeps, + selectionPixels: createSelectionPixelOptions(), + selectionImage: createSelectionImageOptions(), + createSelectionState: () => selection, + text: createTextOptions(), + transform: createTransformOptions(), + }); + const lease = controller.edits.tryAcquire({ kind: 'select-object' }); + + controller.dispose(); + controller.dispose(); + controller.activate(); + + expect(selection.dispose).toHaveBeenCalledTimes(1); + expect(lease?.signal.aborted).toBe(true); + expect(controller.edits.tryAcquire({ kind: 'filter' })).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editingController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editingController.ts new file mode 100644 index 00000000000..38c615a4f99 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editingController.ts @@ -0,0 +1,127 @@ +import type { CanvasEditGate, CanvasEditGateController } from '@workbench/canvas-engine/editGate'; +import type { SelectionState, SelectionStateDeps } from '@workbench/canvas-engine/selection/selectionState'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { getSourceBounds, isRenderableLayer } from '@workbench/canvas-engine/document/sources'; +import { createCanvasEditGate } from '@workbench/canvas-engine/editGate'; +import { roundOut, union } from '@workbench/canvas-engine/math/rect'; +import { createSelectionState } from '@workbench/canvas-engine/selection/selectionState'; + +import { SelectionImageController, type SelectionImageControllerOptions } from './selectionImageController'; +import { SelectionPixelController, type SelectionPixelControllerOptions } from './selectionPixelController'; +import { TextEditingController, type TextEditingControllerOptions } from './textEditingController'; +import { TransformEditingController, type TransformEditingControllerOptions } from './transformEditingController'; + +export interface EditingControllerOptions { + readonly selection: SelectionStateDeps; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly createSelectionState?: (deps: SelectionStateDeps) => SelectionState; + readonly createEditGate?: () => CanvasEditGateController; + readonly text: TextEditingControllerOptions; + readonly transform: TransformEditingControllerOptions; + readonly selectionPixels: Omit; + readonly selectionImage: Omit, 'selection'>; +} + +/** Owns transient editing state whose lifetime follows one engine instance. */ +export class EditingController { + readonly selection: SelectionState; + readonly edits: CanvasEditGate; + readonly text: TextEditingController; + readonly transform: TransformEditingController; + readonly selectionPixels: SelectionPixelController; + readonly selectionImage: SelectionImageController; + private readonly editGate: CanvasEditGateController; + private readonly getDocument: () => CanvasDocumentContractV2 | null; + private disposed = false; + + constructor(options: EditingControllerOptions) { + this.selection = (options.createSelectionState ?? createSelectionState)(options.selection); + this.getDocument = options.getDocument; + this.editGate = (options.createEditGate ?? createCanvasEditGate)(); + this.edits = this.editGate; + this.text = new TextEditingController(options.text); + this.transform = new TransformEditingController(options.transform); + this.selectionPixels = new SelectionPixelController({ ...options.selectionPixels, selection: this.selection }); + this.selectionImage = new SelectionImageController({ + ...options.selectionImage, + selection: this.selection, + }); + } + + activate(): void { + if (!this.disposed) { + this.editGate.activate(); + } + } + + private selectionDomain(): Rect | null { + const document = this.getDocument(); + if (!document) { + return null; + } + let bounds: Rect = { ...document.bbox }; + for (const layer of document.layers) { + if (isRenderableLayer(layer)) { + bounds = union(bounds, getSourceBounds(layer, document)); + } + } + return roundOut(bounds); + } + + selectAll(): void { + const domain = this.selectionDomain(); + if (domain) { + this.selection.selectAll(domain); + } + } + + deselect(): void { + this.selection.clear(); + } + + invertSelection(): void { + const domain = this.selectionDomain(); + if (domain) { + this.selection.invert(domain); + } + } + + cooldown(): void { + if (!this.disposed) { + this.editGate.cooldown(); + } + } + + invalidateDocument(): void { + if (!this.disposed) { + this.editGate.invalidateDocument(); + } + } + + invalidateProject(): void { + if (!this.disposed) { + this.editGate.invalidateProject(); + } + } + + invalidateLayer(layerId: string): void { + if (!this.disposed) { + this.editGate.invalidateLayer(layerId); + } + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.editGate.dispose(); + this.text.dispose(); + this.transform.dispose(); + this.selectionPixels.dispose(); + this.selectionImage.dispose(); + this.selection.dispose(); + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/extractMaskedAreaController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/extractMaskedAreaController.ts new file mode 100644 index 00000000000..4b51cf854b4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/extractMaskedAreaController.ts @@ -0,0 +1,242 @@ +import type { LayerExportGuard } from '@workbench/canvas-engine/api'; +import type { CanvasDiagnostics } from '@workbench/canvas-engine/diagnostics'; +import type { History } from '@workbench/canvas-engine/history/history'; +import type { DerivedSurfaceCache } from '@workbench/canvas-engine/render/derivedSurfaceCache'; +import type { LayerCacheEntry, LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { getSourceContentRect } from '@workbench/canvas-engine/document/sources'; +import { isEmpty } from '@workbench/canvas-engine/math/rect'; +import { compositeDocument } from '@workbench/canvas-engine/render/compositor'; + +export type ExtractMaskedAreaResult = + | { status: 'extracted'; layerId: string } + | { status: 'missing' | 'unsupported' | 'not-ready' | 'busy' | 'empty' }; + +type ExportResult = + | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' }; + +export interface ExtractMaskedAreaControllerOptions { + readonly backend: RasterBackend; + readonly layers: LayerCacheStore; + readonly derived: DerivedSurfaceCache; + readonly diagnostics: CanvasDiagnostics; + readonly history: History; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly getReducerDocument: () => CanvasDocumentContractV2 | null; + readonly capturePermit: () => object | null; + readonly isPermitCurrent: (permit: object) => boolean; + readonly isGestureActive: () => boolean; + readonly endBurst: () => void; + readonly isCacheReady: (layer: CanvasLayerContract, document: CanvasDocumentContractV2) => boolean; + readonly hasExportableContent: (layerId: string) => boolean; + readonly exportBaked: (layerId: string, includeDisabled?: boolean) => Promise; + readonly rasterize: (layerId: string) => Promise; + readonly isGuardCurrent: (guard: LayerExportGuard) => boolean; + readonly getAdjustedSurface: (layer: CanvasLayerContract, entry: LayerCacheEntry) => RasterSurface | null; + readonly getMaskPattern: (style: string, color: string) => RasterSurface | null; + readonly createLayerId: () => string; + readonly preparePixels: (layerId: string, rect: Rect, pixels: RasterSurface) => unknown; + readonly installPrepared: (prepared: unknown) => void; + readonly dispatchPrepared: ( + action: CanvasProjectMutation, + expectedReducer: () => boolean, + expectedMirror: () => boolean + ) => void; +} + +/** Owns guarded extraction of raster content through an inpaint mask. */ +export class ExtractMaskedAreaController { + private disposed = false; + constructor(private readonly deps: ExtractMaskedAreaControllerOptions) {} + + async extract(maskLayerId: string): Promise { + const permit = this.deps.capturePermit(); + if (this.disposed || !permit || this.deps.isGestureActive()) { + return { status: 'busy' }; + } + this.deps.endBurst(); + const document = this.deps.getDocument(); + if (!document) { + return { status: 'missing' }; + } + const maskIndex = document.layers.findIndex((layer) => layer.id === maskLayerId); + const mask = document.layers[maskIndex]; + if (maskIndex < 0 || !mask) { + return { status: 'missing' }; + } + if (mask.type !== 'inpaint_mask' || mask.isLocked) { + return { status: 'unsupported' }; + } + const liveMask = this.deps.layers.get(maskLayerId); + if (isEmpty(getSourceContentRect(mask, document)) && (!liveMask || isEmpty(liveMask.rect))) { + return { status: 'empty' }; + } + const contributors = document.layers.filter( + (layer) => layer.isEnabled && layer.type === 'raster' && this.deps.hasExportableContent(layer.id) + ); + if (contributors.length === 0) { + return { status: 'empty' }; + } + if ( + !this.deps.isCacheReady(mask, document) || + contributors.some((layer) => !this.deps.isCacheReady(layer, document)) + ) { + return { status: 'not-ready' }; + } + const owned: Extract[] = []; + const acquire = async (resultPromise: Promise): Promise => { + const result = await resultPromise; + if (result.status === 'ok') { + owned.push(result); + } + return result; + }; + try { + const settled = await Promise.allSettled([ + acquire(this.deps.exportBaked(maskLayerId, true)), + ...contributors.map((layer) => acquire(this.deps.rasterize(layer.id))), + ]); + const rejected = settled.find((result) => result.status === 'rejected'); + if (rejected?.status === 'rejected') { + throw rejected.reason instanceof Error ? rejected.reason : new Error(String(rejected.reason)); + } + const [maskPixels, ...contributorPixels] = settled.map( + (result) => (result as PromiseFulfilledResult).value + ); + if (!this.deps.isPermitCurrent(permit)) { + return { status: 'busy' }; + } + if (maskPixels.status !== 'ok') { + return { status: maskPixels.status === 'not-ready' ? 'not-ready' : 'empty' }; + } + if (contributorPixels.some((pixels) => pixels.status !== 'ok')) { + return { status: contributorPixels.some((pixels) => pixels.status === 'not-ready') ? 'not-ready' : 'empty' }; + } + if (this.deps.isGestureActive()) { + return { status: 'busy' }; + } + if (maskPixels.guard.layer !== mask || !this.deps.isGuardCurrent(maskPixels.guard)) { + return { status: 'not-ready' }; + } + const liveDocument = this.deps.getDocument(); + const liveMaskIndex = liveDocument?.layers.findIndex((layer) => layer.id === maskLayerId) ?? -1; + const currentMask = liveDocument?.layers[liveMaskIndex]; + if (!liveDocument || !currentMask) { + return { status: 'missing' }; + } + if (currentMask !== mask) { + return { status: currentMask.type === 'inpaint_mask' && currentMask.isLocked ? 'unsupported' : 'not-ready' }; + } + const liveContributors = liveDocument.layers.filter( + (layer) => layer.isEnabled && layer.type === 'raster' && this.deps.hasExportableContent(layer.id) + ); + if ( + liveMaskIndex !== maskIndex || + liveContributors.some((layer, index) => layer !== contributors[index]) || + liveContributors.length !== contributors.length + ) { + return { status: 'not-ready' }; + } + for (let index = 0; index < contributorPixels.length; index += 1) { + const pixels = contributorPixels[index]; + const contributor = contributors[index]; + if ( + !pixels || + pixels.status !== 'ok' || + !contributor || + pixels.guard.layer !== contributor || + !this.deps.isGuardCurrent(pixels.guard) + ) { + return { status: 'not-ready' }; + } + } + const rect = maskPixels.rect; + if (isEmpty(rect)) { + return { status: 'empty' }; + } + const pixels = this.deps.backend.createSurface(rect.width, rect.height); + compositeDocument( + pixels, + { ...document, layers: contributors }, + this.deps.layers, + { a: 1, b: 0, c: 0, d: 1, e: -rect.x, f: -rect.y }, + { + adjustedSurface: this.deps.getAdjustedSurface, + backend: this.deps.backend, + derivedSurfaces: this.deps.derived, + diagnostics: this.deps.diagnostics, + maskPatternTile: this.deps.getMaskPattern, + } + ); + pixels.ctx.setTransform(1, 0, 0, 1, 0, 0); + pixels.ctx.globalAlpha = 1; + pixels.ctx.globalCompositeOperation = 'destination-in'; + pixels.ctx.drawImage(maskPixels.surface.canvas, 0, 0); + const resultId = this.deps.createLayerId(); + const layer: CanvasLayerContract = { + blendMode: 'normal', + id: resultId, + isEnabled: true, + isLocked: false, + name: `${mask.name} extraction`, + opacity: 1, + source: { bitmap: null, offset: { x: rect.x, y: rect.y }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }; + const selectedLayerId = liveDocument.selectedLayerId; + const apply = (): void => { + const prepared = this.deps.preparePixels(resultId, rect, pixels); + this.deps.dispatchPrepared( + { + add: { index: maskIndex, layers: [layer] }, + enabledUpdates: [], + selectedLayerId: resultId, + type: 'applyCanvasLayerStackMutation', + }, + () => + this.deps.getReducerDocument()?.selectedLayerId === resultId && + this.deps.getReducerDocument()?.layers.some((candidate) => candidate === layer) === true, + () => + this.deps.getDocument()?.selectedLayerId === resultId && + this.deps.getDocument()?.layers.some((candidate) => candidate === layer) === true + ); + this.deps.installPrepared(prepared); + }; + if (!this.deps.isPermitCurrent(permit)) { + return { status: 'busy' }; + } + apply(); + this.deps.history.push({ + bytes: rect.width * rect.height * 4 + 256, + label: 'Extract masked area', + redo: apply, + replayFailureAtomic: true, + undo: () => + this.deps.dispatchPrepared( + { enabledUpdates: [], removeIds: [resultId], selectedLayerId, type: 'applyCanvasLayerStackMutation' }, + () => + this.deps.getReducerDocument()?.selectedLayerId === selectedLayerId && + this.deps.getReducerDocument()?.layers.some((candidate) => candidate.id === resultId) === false, + () => + this.deps.getDocument()?.selectedLayerId === selectedLayerId && + this.deps.getDocument()?.layers.some((candidate) => candidate.id === resultId) === false + ), + }); + return { layerId: resultId, status: 'extracted' }; + } finally { + for (const result of owned) { + result.release(); + } + } + } + + dispose(): void { + this.disposed = true; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/filterResultController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/filterResultController.ts new file mode 100644 index 00000000000..50fe03f33b8 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/filterResultController.ts @@ -0,0 +1,261 @@ +import type { LayerExportGuard } from '@workbench/canvas-engine/api'; +import type { CapturedLayerCache } from '@workbench/canvas-engine/controllers/layerMutationController'; +import type { DecodeImageResult } from '@workbench/canvas-engine/controllers/rasterController'; +import type { History } from '@workbench/canvas-engine/history/history'; +import type { PreparedLayerCacheReplacement } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { + CanvasDocumentContractV2, + CanvasImageRef, + CanvasLayerContract, + CanvasRasterLayerContractV2, +} from '@workbench/types'; + +import { createControlLayer } from '@workbench/canvas-engine/document/layerFactories'; +import { LayerFilterOutputDimensionError } from '@workbench/canvas-engine/filterError'; + +export type CommitRasterFilterResult = + | { status: 'committed'; layerId: string } + | { status: 'missing' | 'locked' | 'stale' | 'unsupported' | 'busy' | 'aborted' } + | { status: 'failed'; message: string }; +export interface RasterFilterSettings { + type: string; + settings: Record; +} +export type RasterFilterCommitTarget = 'apply' | 'raster' | 'control'; +export interface CommitRasterFilterOptions { + guard: LayerExportGuard; + image: CanvasImageRef; + rect: Rect; + mode: 'replace' | 'copy'; + filter?: RasterFilterSettings; + target?: RasterFilterCommitTarget; + requireExactImageDimensions?: boolean; + signal?: AbortSignal; +} + +export interface FilterResultControllerOptions { + readonly captureCache: (layer: CanvasLayerContract, document: CanvasDocumentContractV2) => CapturedLayerCache; + readonly capturePermit: (owner?: Owner) => Permit | null; + readonly createLayerId: () => string; + readonly decodeImage: ( + image: CanvasImageRef, + options: { + signal?: AbortSignal; + isCurrent?: () => boolean; + scaleToImage?: boolean; + validateDecoded?: (width: number, height: number) => void; + } + ) => Promise; + readonly discardPersisted: (layerId: string) => void; + readonly dispatchPrepared: ( + action: CanvasProjectMutation, + reducerAccepted: () => boolean, + mirrorAccepted: () => boolean + ) => void; + readonly endBurst: () => void; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly getMainModelBase: () => string | null; + readonly getReducerDocument: () => CanvasDocumentContractV2 | null; + readonly history: History; + readonly installPrepared: (prepared: PreparedLayerCacheReplacement, persist?: boolean) => void; + readonly isGestureActive: () => boolean; + readonly isGuardCurrent: (guard: LayerExportGuard) => boolean; + readonly isPermitCurrent: (permit: Permit) => boolean; + readonly needsPixelPersistence: (layer: CanvasLayerContract) => boolean; + readonly preparePixels: (layerId: string, rect: Rect, pixels: RasterSurface) => PreparedLayerCacheReplacement; +} + +/** Publishes guarded filter results as replacements or independent copies. */ +export class FilterResultController { + constructor(private readonly options: FilterResultControllerOptions) {} + + async commit(options: CommitRasterFilterOptions, owner?: Owner): Promise { + const o = this.options; + const permit = o.capturePermit(owner); + if (!permit) { + return { status: 'busy' }; + } + if (options.signal?.aborted) { + return { status: 'aborted' }; + } + try { + const decoded = await o.decodeImage(options.image, { + isCurrent: () => o.isPermitCurrent(permit), + scaleToImage: false, + signal: options.signal, + validateDecoded: (width, height) => { + if ( + options.requireExactImageDimensions && + (width !== options.image.width || height !== options.image.height) + ) { + throw new LayerFilterOutputDimensionError( + options.filter?.type ?? 'decoded_filter', + { height, width }, + { height: options.image.height, width: options.image.width, x: options.rect.x, y: options.rect.y } + ); + } + }, + }); + if (decoded.status !== 'ok') { + return { status: decoded.status === 'aborted' ? 'aborted' : 'busy' }; + } + const pixels = decoded.surface; + const document = o.getDocument(); + const liveLayer = document?.layers.find((candidate) => candidate.id === options.guard.layerId); + if (!document || !liveLayer) { + return { status: 'missing' }; + } + if (liveLayer.isLocked) { + return { status: 'locked' }; + } + if (liveLayer.type !== 'raster' && liveLayer.type !== 'control') { + return { status: 'unsupported' }; + } + if (!o.isPermitCurrent(permit) || o.isGestureActive()) { + return { status: 'busy' }; + } + if (!o.isGuardCurrent(options.guard)) { + return { status: 'stale' }; + } + if (options.signal?.aborted) { + return { status: 'aborted' }; + } + o.endBurst(); + const image = structuredClone(options.image); + const rect = { ...options.rect }; + const paintSource = { bitmap: image, offset: { x: rect.x, y: rect.y }, type: 'paint' } as const; + if (options.mode === 'replace') { + const beforePixels = o.captureCache(liveLayer, document); + if (!beforePixels || beforePixels === 'not-ready') { + return { status: 'stale' }; + } + const before = structuredClone(liveLayer); + const after: CanvasLayerContract = + liveLayer.type === 'raster' + ? (() => { + const { adjustments: _adjustments, ...base } = liveLayer; + return structuredClone({ ...base, filter: options.filter, source: paintSource }); + })() + : structuredClone({ ...liveLayer, filter: options.filter, source: paintSource }); + const afterPixels = { pixels, rect }; + const publish = ( + contract: CanvasLayerContract, + prepared: PreparedLayerCacheReplacement, + publishOptions: { discardPersistence: boolean; persist: boolean } + ): void => { + o.dispatchPrepared( + { layer: contract, layerId: liveLayer.id, type: 'replaceCanvasLayer' }, + () => o.getReducerDocument()?.layers.find((candidate) => candidate.id === liveLayer.id) === contract, + () => o.getDocument()?.layers.find((candidate) => candidate.id === liveLayer.id) === contract + ); + if (publishOptions.discardPersistence) { + try { + o.discardPersisted(liveLayer.id); + } catch { + /* Ancillary after commit. */ + } + } + o.installPrepared(prepared, publishOptions.persist); + }; + const apply = ( + contract: CanvasLayerContract, + snapshot: { pixels: RasterSurface; rect: Rect }, + publishOptions: { discardPersistence: boolean; persist: boolean } + ): void => publish(contract, o.preparePixels(liveLayer.id, snapshot.rect, snapshot.pixels), publishOptions); + publish(after, o.preparePixels(liveLayer.id, rect, pixels), { discardPersistence: true, persist: false }); + o.history.push({ + bytes: beforePixels.rect.width * beforePixels.rect.height * 4 + rect.width * rect.height * 4 + 256, + label: 'Replace layer with filter result', + redo: () => apply(after, afterPixels, { discardPersistence: true, persist: false }), + replayFailureAtomic: true, + undo: () => + apply(before, beforePixels, { discardPersistence: false, persist: o.needsPixelPersistence(before) }), + }); + return { layerId: liveLayer.id, status: 'committed' }; + } + const sourceIndex = document.layers.findIndex((candidate) => candidate.id === liveLayer.id); + if (sourceIndex < 0) { + return { status: 'missing' }; + } + const selectedLayerId = document.selectedLayerId; + const layerId = o.createLayerId(); + let copy: CanvasLayerContract; + if (options.target === 'control') { + const base = + liveLayer.type === 'control' + ? structuredClone(liveLayer) + : createControlLayer(`${liveLayer.name} filtered`, layerId, o.getMainModelBase()); + copy = { + ...base, + filter: options.filter, + id: layerId, + name: `${liveLayer.name} filtered`, + source: paintSource, + transform: structuredClone(liveLayer.transform), + }; + } else if (options.target === 'raster' && liveLayer.type === 'control') { + copy = { + blendMode: liveLayer.blendMode, + filter: options.filter, + id: layerId, + isEnabled: true, + isLocked: false, + name: `${liveLayer.name} filtered`, + opacity: liveLayer.opacity, + source: paintSource, + transform: structuredClone(liveLayer.transform), + type: 'raster', + }; + } else { + const { adjustments: _adjustments, ...base } = structuredClone(liveLayer as CanvasRasterLayerContractV2); + copy = { + ...base, + filter: options.filter, + id: layerId, + name: `${liveLayer.name} filtered`, + source: paintSource, + type: 'raster', + }; + } + const apply = (): void => { + const prepared = o.preparePixels(layerId, rect, pixels); + o.dispatchPrepared( + { index: sourceIndex, layer: copy, type: 'addCanvasLayer' }, + () => o.getReducerDocument()?.layers.some((candidate) => candidate === copy) === true, + () => o.getDocument()?.layers.some((candidate) => candidate === copy) === true + ); + o.installPrepared(prepared, false); + }; + apply(); + o.history.push({ + bytes: rect.width * rect.height * 4 + 256, + label: 'Copy layer filter result', + redo: apply, + replayFailureAtomic: true, + undo: () => { + o.dispatchPrepared( + { id: selectedLayerId, type: 'setCanvasSelectedLayer' }, + () => o.getReducerDocument()?.selectedLayerId === selectedLayerId, + () => o.getDocument()?.selectedLayerId === selectedLayerId + ); + o.dispatchPrepared( + { ids: [layerId], type: 'removeCanvasLayers' }, + () => o.getReducerDocument()?.layers.some((candidate) => candidate.id === layerId) === false, + () => o.getDocument()?.layers.some((candidate) => candidate.id === layerId) === false + ); + }, + }); + return { layerId, status: 'committed' }; + } catch (error) { + if (options.signal?.aborted || (error instanceof Error && error.name === 'AbortError')) { + return { status: 'aborted' }; + } + return { message: error instanceof Error ? error.message : String(error), status: 'failed' }; + } + } + + dispose(): void {} +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/generatedResultController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/generatedResultController.ts new file mode 100644 index 00000000000..c3b746942ad --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/generatedResultController.ts @@ -0,0 +1,253 @@ +import type { + CommitGeneratedImageOptions, + CommitGeneratedImageResult, + LayerExportGuard, +} from '@workbench/canvas-engine/api'; +import type { CapturedLayerCache } from '@workbench/canvas-engine/controllers/layerMutationController'; +import type { DecodeImageResult } from '@workbench/canvas-engine/controllers/rasterController'; +import type { History } from '@workbench/canvas-engine/history/history'; +import type { PreparedLayerCacheReplacement } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { LayerTransform } from '@workbench/canvas-engine/transform/transformMath'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasImageRef, CanvasLayerContract } from '@workbench/types'; + +import { createControlLayer, nextControlLayerName } from '@workbench/canvas-engine/document/layerFactories'; + +export interface GeneratedResultControllerOptions { + readonly captureCache: (layer: CanvasLayerContract, document: CanvasDocumentContractV2) => CapturedLayerCache; + readonly capturePermit: (owner?: Owner) => Permit | null; + readonly clearPreview: (layerId: string) => void; + readonly createLayerId: () => string; + readonly decodeImage: ( + image: CanvasImageRef, + options: { signal?: AbortSignal; isCurrent?: () => boolean } + ) => Promise; + readonly discardPersisted: (layerId: string) => void; + readonly dispatchPrepared: ( + action: CanvasProjectMutation, + reducerAccepted: () => boolean, + mirrorAccepted: () => boolean + ) => void; + readonly endBurst: () => void; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly getMainModelBase: () => string | null; + readonly getReducerDocument: () => CanvasDocumentContractV2 | null; + readonly history: History; + readonly installPrepared: (prepared: PreparedLayerCacheReplacement, persist?: boolean) => void; + readonly isGestureActive: () => boolean; + readonly isGuardCurrent: (guard: LayerExportGuard) => boolean; + readonly isPermitCurrent: (permit: Permit) => boolean; + readonly needsPixelPersistence: (layer: CanvasLayerContract) => boolean; + readonly preparePixels: (layerId: string, rect: Rect, pixels: RasterSurface) => PreparedLayerCacheReplacement; +} + +/** Publishes guarded workflow/SAM images as replacements or copies. */ +export class GeneratedResultController { + constructor(private readonly options: GeneratedResultControllerOptions) {} + + async commit(options: CommitGeneratedImageOptions, owner?: Owner): Promise { + const o = this.options; + const permit = o.capturePermit(owner); + if (!permit) { + return { status: 'busy' }; + } + if (options.signal?.aborted) { + return { status: 'aborted' }; + } + const validate = (): + | { document: CanvasDocumentContractV2; liveLayer: Extract } + | { result: CommitGeneratedImageResult } => { + if (!o.isPermitCurrent(permit)) { + return { result: { status: 'busy' } }; + } + const document = o.getDocument(); + if (!document) { + return { result: { status: 'missing' } }; + } + const liveLayer = document.layers.find((layer) => layer.id === options.guard.layerId); + if (!liveLayer) { + return { result: { status: 'missing' } }; + } + if (liveLayer.isLocked) { + return { result: { status: 'locked' } }; + } + if (liveLayer.type !== 'raster' && liveLayer.type !== 'control') { + return { result: { status: 'unsupported' } }; + } + if (o.isGestureActive()) { + return { result: { status: 'busy' } }; + } + if (!o.isGuardCurrent(options.guard)) { + return { result: { status: 'stale' } }; + } + return { document, liveLayer }; + }; + try { + const decoded = await o.decodeImage(options.image, { + isCurrent: () => o.isPermitCurrent(permit), + signal: options.signal, + }); + if (decoded.status !== 'ok') { + return { status: decoded.status === 'aborted' ? 'aborted' : 'busy' }; + } + const checked = validate(); + if ('result' in checked) { + return checked.result; + } + const { document, liveLayer } = checked; + const image = structuredClone(options.image); + const origin = { ...options.origin }; + const rect = { height: image.height, width: image.width, ...origin }; + const source = { bitmap: image, offset: origin, type: 'paint' } as const; + const identityTransform: LayerTransform = { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }; + const publishSnapshot = ( + contract: CanvasLayerContract, + prepared: PreparedLayerCacheReplacement, + publishOptions: { discardPersistence: boolean; persist: boolean } + ): void => { + o.dispatchPrepared( + { layer: contract, layerId: liveLayer.id, type: 'replaceCanvasLayer' }, + () => o.getReducerDocument()?.layers.find((candidate) => candidate.id === liveLayer.id) === contract, + () => o.getDocument()?.layers.find((candidate) => candidate.id === liveLayer.id) === contract + ); + if (publishOptions.discardPersistence) { + try { + o.discardPersisted(liveLayer.id); + } catch { + /* Ancillary after reducer acceptance. */ + } + } + try { + o.clearPreview(liveLayer.id); + } catch { + /* Transient preview is ancillary. */ + } + o.installPrepared(prepared, publishOptions.persist); + }; + const applySnapshot = ( + contract: CanvasLayerContract, + snapshot: { pixels: RasterSurface; rect: Rect }, + publishOptions: { discardPersistence: boolean; persist: boolean } + ): void => + publishSnapshot(contract, o.preparePixels(liveLayer.id, snapshot.rect, snapshot.pixels), publishOptions); + if (options.target === 'replace') { + const beforePixels = o.captureCache(liveLayer, document); + if (!beforePixels || beforePixels === 'not-ready') { + return { status: 'stale' }; + } + if (options.signal?.aborted) { + return { status: 'aborted' }; + } + const before = structuredClone(liveLayer); + let after: CanvasLayerContract; + if (liveLayer.type === 'raster') { + const { adjustments: _adjustments, ...base } = structuredClone(liveLayer); + after = { ...base, source, transform: identityTransform }; + } else { + after = { ...structuredClone(liveLayer), source, transform: identityTransform }; + } + const afterPixels = { pixels: decoded.surface, rect }; + const prepared = o.preparePixels(liveLayer.id, rect, decoded.surface); + if (options.signal?.aborted) { + return { status: 'aborted' }; + } + const finalCheck = validate(); + if ('result' in finalCheck) { + return finalCheck.result; + } + o.endBurst(); + publishSnapshot(after, prepared, { discardPersistence: true, persist: false }); + o.history.push({ + bytes: beforePixels.rect.width * beforePixels.rect.height * 4 + rect.width * rect.height * 4 + 256, + label: options.historyLabel ?? 'Replace layer with workflow result', + redo: () => applySnapshot(after, afterPixels, { discardPersistence: true, persist: false }), + replayFailureAtomic: true, + undo: () => + applySnapshot(before, beforePixels, { + discardPersistence: false, + persist: o.needsPixelPersistence(before), + }), + }); + return { layerId: liveLayer.id, status: 'committed' }; + } + const sourceIndex = document.layers.findIndex((candidate) => candidate.id === liveLayer.id); + if (sourceIndex < 0) { + return { status: 'missing' }; + } + const layerId = o.createLayerId(); + const selectedLayerId = document.selectedLayerId; + const copy: CanvasLayerContract = + options.target === 'copy-control' + ? { + ...createControlLayer( + options.copyLayerName ?? nextControlLayerName(document.layers.map((layer) => layer.name)), + layerId, + o.getMainModelBase() + ), + source, + transform: identityTransform, + } + : { + blendMode: 'normal', + id: layerId, + isEnabled: true, + isLocked: false, + name: options.copyLayerName ?? `${liveLayer.name} workflow result`, + opacity: 1, + source, + transform: identityTransform, + type: 'raster', + }; + const publishCopy = (prepared: PreparedLayerCacheReplacement): void => { + o.dispatchPrepared( + { index: sourceIndex, layer: copy, type: 'addCanvasLayer' }, + () => o.getReducerDocument()?.layers.some((candidate) => candidate === copy) === true, + () => o.getDocument()?.layers.some((candidate) => candidate === copy) === true + ); + o.installPrepared(prepared, false); + }; + const applyCopy = (): void => publishCopy(o.preparePixels(layerId, rect, decoded.surface)); + const prepared = o.preparePixels(layerId, rect, decoded.surface); + if (options.signal?.aborted) { + return { status: 'aborted' }; + } + const finalCheck = validate(); + if ('result' in finalCheck) { + return finalCheck.result; + } + o.endBurst(); + publishCopy(prepared); + o.history.push({ + bytes: rect.width * rect.height * 4 + 256, + label: + options.target === 'copy-control' + ? 'Copy workflow result to control layer' + : 'Copy workflow result to raster layer', + redo: applyCopy, + replayFailureAtomic: true, + undo: () => { + o.dispatchPrepared( + { id: selectedLayerId, type: 'setCanvasSelectedLayer' }, + () => o.getReducerDocument()?.selectedLayerId === selectedLayerId, + () => o.getDocument()?.selectedLayerId === selectedLayerId + ); + o.dispatchPrepared( + { ids: [layerId], type: 'removeCanvasLayers' }, + () => o.getReducerDocument()?.layers.some((candidate) => candidate.id === layerId) === false, + () => o.getDocument()?.layers.some((candidate) => candidate.id === layerId) === false + ); + }, + }); + return { layerId, status: 'committed' }; + } catch (error) { + if (options.signal?.aborted || (error instanceof Error && error.name === 'AbortError')) { + return { status: 'aborted' }; + } + return { message: error instanceof Error ? error.message : String(error), status: 'failed' }; + } + } + + dispose(): void {} +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/historyController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/historyController.test.ts new file mode 100644 index 00000000000..1cdd825910a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/historyController.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { HistoryController } from './historyController'; + +describe('HistoryController', () => { + it('owns history and trims it to the inactive byte budget', () => { + const controller = new HistoryController({ activeByteBudget: 1_000, inactiveByteBudget: 100 }); + controller.history.push({ bytes: 60, label: 'old', redo: () => undefined, undo: () => undefined }); + controller.history.push({ bytes: 70, label: 'new', redo: () => undefined, undo: () => undefined }); + + controller.cooldown(); + + expect(controller.history.byteSize()).toBe(70); + expect(controller.history.canUndo()).toBe(true); + controller.dispose(); + controller.dispose(); + expect(controller.history.byteSize()).toBe(0); + }); + + it('owns guarded commands and synchronizes undo/redo stores', () => { + let canEdit = true; + let gestureActive = false; + const canUndo = { set: vi.fn() }; + const canRedo = { set: vi.fn() }; + const endBurst = vi.fn(); + const controller = new HistoryController({ + canEdit: () => canEdit, + canRedoStore: canRedo, + canUndoStore: canUndo, + endBurst, + isGestureActive: () => gestureActive, + }); + const undo = vi.fn(); + const redo = vi.fn(); + controller.history.push({ bytes: 1, label: 'edit', redo, undo }); + + controller.undo(); + expect(undo).toHaveBeenCalledOnce(); + expect(endBurst).toHaveBeenCalledOnce(); + expect(canUndo.set).toHaveBeenLastCalledWith(false); + expect(canRedo.set).toHaveBeenLastCalledWith(true); + + gestureActive = true; + controller.redo(); + expect(redo).not.toHaveBeenCalled(); + gestureActive = false; + canEdit = false; + controller.clear(); + expect(controller.history.canRedo()).toBe(true); + + canEdit = true; + controller.clear(); + expect(controller.history.canRedo()).toBe(false); + controller.dispose(); + }); + + it('isolates store subscriber failures while synchronizing both flags', () => { + const canUndo = { + set: vi.fn(() => { + throw new Error('observer'); + }), + }; + const canRedo = { set: vi.fn() }; + const controller = new HistoryController({ canRedoStore: canRedo, canUndoStore: canUndo }); + + expect(() => + controller.history.push({ bytes: 1, label: 'edit', redo: () => undefined, undo: () => undefined }) + ).not.toThrow(); + expect(canRedo.set).toHaveBeenCalled(); + controller.dispose(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/historyController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/historyController.ts new file mode 100644 index 00000000000..3615e511cf5 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/historyController.ts @@ -0,0 +1,84 @@ +import { createHistory, HISTORY_BYTE_BUDGET, type History } from '@workbench/canvas-engine/history/history'; + +export const INACTIVE_HISTORY_BYTE_BUDGET = 64 * 1024 * 1024; + +export interface HistoryControllerOptions { + readonly activeByteBudget?: number; + readonly inactiveByteBudget?: number; + readonly canEdit?: () => boolean; + readonly isGestureActive?: () => boolean; + readonly endBurst?: () => void; + readonly canUndoStore?: { set(value: boolean): void }; + readonly canRedoStore?: { set(value: boolean): void }; +} + +export class HistoryController { + readonly history: History; + private readonly inactiveByteBudget: number; + private readonly canEdit: () => boolean; + private readonly isGestureActive: () => boolean; + private readonly endBurst: () => void; + private readonly unsubscribe: () => void; + private disposed = false; + + constructor(options: HistoryControllerOptions = {}) { + this.inactiveByteBudget = options.inactiveByteBudget ?? INACTIVE_HISTORY_BYTE_BUDGET; + this.history = createHistory({ byteBudget: options.activeByteBudget ?? HISTORY_BYTE_BUDGET }); + this.canEdit = options.canEdit ?? (() => true); + this.isGestureActive = options.isGestureActive ?? (() => false); + this.endBurst = options.endBurst ?? (() => undefined); + const syncStores = (): void => { + try { + options.canUndoStore?.set(this.history.canUndo()); + } catch { + // Store observers are ancillary and must not break history transactions. + } + try { + options.canRedoStore?.set(this.history.canRedo()); + } catch { + // Keep the two notifications isolated from one another. + } + }; + this.unsubscribe = this.history.subscribe(syncStores); + syncStores(); + } + + undo(): void { + if (this.disposed || !this.canEdit() || this.isGestureActive()) { + return; + } + this.endBurst(); + this.history.undo(); + } + + redo(): void { + if (this.disposed || !this.canEdit() || this.isGestureActive()) { + return; + } + this.endBurst(); + this.history.redo(); + } + + clear(): void { + if (this.disposed || !this.canEdit()) { + return; + } + this.endBurst(); + this.history.clear(); + } + + cooldown(): void { + if (!this.disposed) { + this.history.trimToBytes(this.inactiveByteBudget); + } + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.unsubscribe(); + this.history.clear(); + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/interactionController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/interactionController.test.ts new file mode 100644 index 00000000000..7aae8c4d489 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/interactionController.test.ts @@ -0,0 +1,67 @@ +import type { Tool } from '@workbench/canvas-engine/tools/tool'; + +import { describe, expect, it, vi } from 'vitest'; + +import { InteractionController } from './interactionController'; + +const createHarness = () => { + const brush = { onActivate: vi.fn(), onDeactivate: vi.fn() } as unknown as Tool; + const view = { onActivate: vi.fn(), onDeactivate: vi.fn() } as unknown as Tool; + const tools = { brush, view }; + const publishActiveTool = vi.fn(); + const updateCursor = vi.fn(); + const invalidateOverlay = vi.fn(); + const beforeSwitch = vi.fn(); + const stepBrushSize = vi.fn(); + let locked = false; + const controller = new InteractionController({ + beforeSwitch, + getTool: (id) => tools[id as keyof typeof tools], + getToolContext: () => ({}) as never, + invalidateOverlay, + isLocked: () => locked, + publishActiveTool, + stepBrushSize, + updateCursor, + }); + return { + beforeSwitch, + brush, + controller, + invalidateOverlay, + lock: () => (locked = true), + publishActiveTool, + stepBrushSize, + updateCursor, + view, + }; +}; + +describe('InteractionController', () => { + it('owns active-tool transitions and publishes their effects', () => { + const h = createHarness(); + h.controller.tools.setTool('brush', { temporary: true }); + + expect(h.beforeSwitch).toHaveBeenCalledWith('view', 'brush', { temporary: true }); + expect(h.view.onDeactivate).toHaveBeenCalledOnce(); + expect(h.brush.onActivate).toHaveBeenCalledOnce(); + expect(h.controller.getActiveToolId()).toBe('brush'); + expect(h.publishActiveTool).toHaveBeenCalledWith('brush'); + expect(h.updateCursor).toHaveBeenCalledOnce(); + expect(h.invalidateOverlay).toHaveBeenCalledOnce(); + }); + + it('blocks non-view transitions while locked and ignores commands after disposal', () => { + const h = createHarness(); + h.lock(); + h.controller.setTool('brush'); + expect(h.controller.getActiveToolId()).toBe('view'); + + h.controller.tools.stepBrushSize(1); + expect(h.stepBrushSize).toHaveBeenCalledWith(1); + h.controller.dispose(); + h.controller.dispose(); + h.controller.tools.stepBrushSize(-1); + expect(h.stepBrushSize).toHaveBeenCalledOnce(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/interactionController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/interactionController.ts new file mode 100644 index 00000000000..e017b9ca35f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/interactionController.ts @@ -0,0 +1,60 @@ +import type { CanvasToolCapability } from '@workbench/canvas-engine/api'; +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { ToolId } from '@workbench/canvas-engine/types'; + +export interface InteractionControllerOptions { + readonly initialToolId?: ToolId; + readonly getTool: (toolId: ToolId) => Tool | undefined; + readonly getToolContext: () => ToolContext; + readonly isLocked: () => boolean; + readonly beforeSwitch?: (from: ToolId, to: ToolId, options?: { temporary?: boolean }) => void; + readonly publishActiveTool: (toolId: ToolId) => void; + readonly updateCursor: () => void; + readonly invalidateOverlay: () => void; + readonly stepBrushSize: (direction: 1 | -1) => void; +} + +/** Owns active-tool transitions and the public tool command boundary. */ +export class InteractionController { + readonly tools: CanvasToolCapability; + private activeToolId: ToolId; + private disposed = false; + + constructor(private readonly options: InteractionControllerOptions) { + this.activeToolId = options.initialToolId ?? 'view'; + this.tools = { + setTool: (toolId, switchOptions) => this.setTool(toolId, switchOptions), + stepBrushSize: (direction) => { + if (!this.disposed) { + options.stepBrushSize(direction); + } + }, + }; + } + + getActiveToolId(): ToolId { + return this.activeToolId; + } + + getActiveTool(): Tool | undefined { + return this.options.getTool(this.activeToolId); + } + + setTool(toolId: ToolId, switchOptions?: { temporary?: boolean }): void { + if (this.disposed || (this.options.isLocked() && toolId !== 'view') || toolId === this.activeToolId) { + return; + } + const previous = this.activeToolId; + this.options.beforeSwitch?.(previous, toolId, switchOptions); + this.options.getTool(previous)?.onDeactivate?.(this.options.getToolContext(), switchOptions); + this.activeToolId = toolId; + this.options.getTool(toolId)?.onActivate?.(this.options.getToolContext(), switchOptions); + this.options.publishActiveTool(toolId); + this.options.updateCursor(); + this.options.invalidateOverlay(); + } + + dispose(): void { + this.disposed = true; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerController.test.ts new file mode 100644 index 00000000000..9ac7241f41e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerController.test.ts @@ -0,0 +1,200 @@ +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; + +import { describe, expect, it, vi } from 'vitest'; + +import { LayerController } from './layerController'; +import { StructuralLayerController } from './structuralLayerController'; + +describe('LayerController', () => { + const mask = { + applyImagePatch: vi.fn(), + canEdit: () => true, + deleteDerived: vi.fn(), + discardPersisted: vi.fn(), + dispatch: vi.fn(), + endBurst: vi.fn(), + getDocument: () => null, + history: {} as never, + isCacheReady: () => false, + isGestureActive: () => false, + layers: {} as never, + markDirty: vi.fn(), + notifyPainted: vi.fn(), + restoreCache: vi.fn(), + }; + const thumbnail = { + backend: {} as never, + getActiveProjectId: () => null, + getCheckerboard: vi.fn(), + getDocument: () => null, + getEntry: () => undefined, + getMaskPattern: () => null, + isDisposed: () => false, + isSupportedSource: () => true, + projectId: 'p1', + rasterize: vi.fn(), + reportError: vi.fn(), + setStatus: vi.fn(), + }; + const structural = new StructuralLayerController({ + canEdit: () => true, + dispatch: vi.fn(), + getDocument: () => null, + history: { push: vi.fn() } as never, + isGestureActive: () => false, + }); + const rasterize = { + backend: {} as never, + canEdit: () => true, + dispatch: vi.fn(), + endBurst: vi.fn(), + getDocument: () => null, + history: {} as never, + isGestureActive: () => false, + layers: {} as never, + markDirty: vi.fn(), + notifyPainted: vi.fn(), + rasterizeDeps: vi.fn(), + }; + const merge = { + backend: {} as never, + canEdit: () => true, + capturePermit: () => null, + createLayerId: () => 'merged', + dispatch: vi.fn(), + dispatchPrepared: vi.fn(), + endBurst: vi.fn(), + exportBaked: vi.fn(), + getDocument: () => null, + getReducerDocument: () => null, + hasExportableContent: () => false, + history: {} as never, + installPrepared: vi.fn(), + isCacheReady: () => true, + isGestureActive: () => false, + isGuardCurrent: () => true, + isPermitCurrent: () => true, + layers: {} as never, + markDirty: vi.fn(), + notifyPainted: vi.fn(), + preparePixels: vi.fn(), + }; + const booleanMerge = { + backend: {} as never, + capturePermit: () => null, + createLayerId: () => 'result', + dispatchPrepared: vi.fn(), + endBurst: vi.fn(), + exportBaked: vi.fn(), + getDocument: () => null, + getReducerDocument: () => null, + history: {} as never, + installPrepared: vi.fn(), + isCacheReady: () => true, + isGestureActive: () => false, + isGuardCurrent: () => true, + isPermitCurrent: () => true, + preparePixels: vi.fn(), + }; + const extractMaskedArea = { + backend: {} as never, + capturePermit: () => null, + createLayerId: () => 'result', + derived: {} as never, + diagnostics: {} as never, + dispatchPrepared: vi.fn(), + endBurst: vi.fn(), + exportBaked: vi.fn(), + getAdjustedSurface: vi.fn(), + getDocument: () => null, + getMaskPattern: () => null, + getReducerDocument: () => null, + hasExportableContent: () => false, + history: {} as never, + installPrepared: vi.fn(), + isCacheReady: () => true, + isGestureActive: () => false, + isGuardCurrent: () => true, + isPermitCurrent: () => true, + layers: {} as never, + preparePixels: vi.fn(), + rasterize: vi.fn(), + }; + const crop = { + backend: {} as never, + captureCache: vi.fn(), + capturePermit: () => null, + discardPersisted: vi.fn(), + dispatchPrepared: vi.fn(), + endBurst: vi.fn(), + exportBaked: vi.fn(), + getDocument: () => null, + getReducerDocument: () => null, + history: {} as never, + installPrepared: vi.fn(), + isGestureActive: () => false, + isGuardCurrent: () => true, + isPermitCurrent: () => true, + isSupportedSource: () => true, + preparePixels: vi.fn(), + }; + const copy = { + capturePermit: () => null, + createLayerId: () => 'copy', + dispatchPrepared: vi.fn(), + endBurst: vi.fn(), + exportBaked: vi.fn(), + getDocument: () => null, + getReducerDocument: () => null, + history: {} as never, + installPrepared: vi.fn(), + isGestureActive: () => false, + isGuardCurrent: () => true, + isPermitCurrent: () => true, + preparePixels: vi.fn(), + }; + it('exposes only declared layer and preview ports', async () => { + const forward: CanvasProjectMutation = { id: 'layer', type: 'setCanvasSelectedLayer' }; + const inverse: CanvasProjectMutation = { id: null, type: 'setCanvasSelectedLayer' }; + const deps = { + commitGeneratedImageResult: vi.fn(() => Promise.resolve({ layerId: 'copy', status: 'committed' as const })), + mask, + booleanMerge, + extractMaskedArea, + crop, + copy, + merge, + thumbnail, + structural, + rasterize, + }; + const controller = new LayerController(deps); + + expect(controller.layers.applyStructuralPreview(forward)).toBe(true); + controller.layers.commitStructural('edit', forward, inverse); + expect(controller.previews.drawLayerThumbnail('layer', {} as HTMLCanvasElement, 96)).toBe(false); + await expect(controller.previews.requestLayerThumbnail('layer')).resolves.toBe('stale'); + }); + + it('disposes idempotently and rejects later mutations', () => { + const deps = { + commitGeneratedImageResult: vi.fn(() => Promise.resolve({ layerId: 'copy', status: 'committed' as const })), + mask, + booleanMerge, + extractMaskedArea, + crop, + copy, + merge, + thumbnail, + structural, + rasterize, + }; + const controller = new LayerController(deps); + controller.dispose(); + controller.dispose(); + + expect(controller.layers.applyStructuralPreview({} as CanvasProjectMutation)).toBe(false); + controller.layers.commitStructural('late', {} as CanvasProjectMutation, {} as CanvasProjectMutation); + expect(controller.previews.drawLayerThumbnail('layer', {} as HTMLCanvasElement, 96)).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerController.ts new file mode 100644 index 00000000000..0318b34f9f6 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerController.ts @@ -0,0 +1,83 @@ +import type { CanvasLayerCapability, CanvasPreviewCapability } from '@workbench/canvas-engine/api'; + +import type { MaskLayerControllerOptions } from './maskLayerController'; +import type { StructuralLayerController } from './structuralLayerController'; + +import { BooleanMergeController, type BooleanMergeControllerOptions } from './booleanMergeController'; +import { CopyLayerController, type CopyLayerControllerOptions } from './copyLayerController'; +import { CropLayerController, type CropLayerControllerOptions } from './cropLayerController'; +import { ExtractMaskedAreaController, type ExtractMaskedAreaControllerOptions } from './extractMaskedAreaController'; +import { MaskLayerController } from './maskLayerController'; +import { MergeLayerController, type MergeLayerControllerOptions } from './mergeLayerController'; +import { RasterizeLayerController, type RasterizeLayerControllerOptions } from './rasterizeLayerController'; +import { ThumbnailController, type ThumbnailControllerOptions } from './thumbnailController'; + +export type LayerControllerDeps = Omit< + CanvasLayerCapability, + 'applyStructuralPreview' | 'canCommitStructural' | 'commitStagedImage' | 'commitStructural' | 'invertMask' +> & + Omit & { + mask: MaskLayerControllerOptions; + thumbnail: ThumbnailControllerOptions; + structural: StructuralLayerController; + rasterize: RasterizeLayerControllerOptions; + merge: MergeLayerControllerOptions; + booleanMerge: BooleanMergeControllerOptions; + extractMaskedArea: ExtractMaskedAreaControllerOptions; + crop: CropLayerControllerOptions; + copy: CopyLayerControllerOptions; + }; + +/** Public layer-operation boundary. Implementations are injected by the composition root. */ +export class LayerController { + readonly layers: Omit; + readonly previews: CanvasPreviewCapability; + readonly mask: MaskLayerController; + readonly thumbnail: ThumbnailController; + readonly structural: StructuralLayerController; + readonly rasterize: RasterizeLayerController; + readonly merge: MergeLayerController; + readonly booleanMerge: BooleanMergeController; + readonly extractMaskedArea: ExtractMaskedAreaController; + readonly crop: CropLayerController; + readonly copy: CopyLayerController; + private disposed = false; + + constructor(deps: LayerControllerDeps) { + this.mask = new MaskLayerController(deps.mask); + this.thumbnail = new ThumbnailController(deps.thumbnail); + this.structural = deps.structural; + this.rasterize = new RasterizeLayerController(deps.rasterize); + this.merge = new MergeLayerController(deps.merge); + this.booleanMerge = new BooleanMergeController(deps.booleanMerge); + this.extractMaskedArea = new ExtractMaskedAreaController(deps.extractMaskedArea); + this.crop = new CropLayerController(deps.crop); + this.copy = new CopyLayerController(deps.copy); + this.layers = { + applyStructuralPreview: (action) => (this.disposed ? false : this.structural.preview(action)), + canCommitStructural: () => this.structural.canCommit(), + commitGeneratedImageResult: (options) => + this.disposed ? Promise.resolve({ status: 'aborted' }) : deps.commitGeneratedImageResult(options), + commitStructural: (label, forward, inverse) => this.structural.commit(label, forward, inverse), + invertMask: (layerId) => (this.disposed ? false : this.mask.invert(layerId)), + }; + this.previews = { + drawLayerThumbnail: (layerId, target, maxSize) => + this.disposed ? false : this.thumbnail.draw(layerId, target, maxSize), + requestLayerThumbnail: (layerId) => (this.disposed ? Promise.resolve('stale') : this.thumbnail.request(layerId)), + }; + } + + dispose(): void { + this.disposed = true; + this.mask.dispose(); + this.thumbnail.dispose(); + this.structural.dispose(); + this.rasterize.dispose(); + this.merge.dispose(); + this.booleanMerge.dispose(); + this.extractMaskedArea.dispose(); + this.crop.dispose(); + this.copy.dispose(); + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerMutationController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerMutationController.ts new file mode 100644 index 00000000000..6583424137b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerMutationController.ts @@ -0,0 +1,134 @@ +import type { History } from '@workbench/canvas-engine/history/history'; +import type { PreparedLayerCacheReplacement } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +export type CapturedLayerCache = { pixels: RasterSurface; rect: Rect } | null | 'not-ready'; + +export interface LayerMutationControllerOptions { + readonly canEdit: () => boolean; + readonly captureCache: (layer: CanvasLayerContract, document: CanvasDocumentContractV2) => CapturedLayerCache; + readonly discardPersisted: (layerId: string) => void; + readonly dispatchPrepared: ( + action: CanvasProjectMutation, + reducerAccepted: () => boolean, + mirrorAccepted: () => boolean + ) => void; + readonly endBurst: () => void; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly getReducerDocument: () => CanvasDocumentContractV2 | null; + readonly history: History; + readonly installPrepared: (prepared: PreparedLayerCacheReplacement, persist?: boolean) => void; + readonly isGestureActive: () => boolean; + readonly needsPixelPersistence: (layer: CanvasLayerContract) => boolean; + readonly preparePixels: (layerId: string, rect: Rect, pixels: RasterSurface) => PreparedLayerCacheReplacement; + readonly sameContract: (document: CanvasDocumentContractV2 | null, layer: CanvasLayerContract) => boolean; +} + +/** Owns failure-atomic copy and cross-type conversion mutations. */ +export class LayerMutationController { + constructor(private readonly options: LayerMutationControllerOptions) {} + + copy(label: string, sourceLayerId: string, layer: CanvasLayerContract, index: number): boolean { + const o = this.options; + if (!o.canEdit() || o.isGestureActive()) { + return false; + } + o.endBurst(); + const document = o.getDocument(); + const source = document?.layers.find((candidate) => candidate.id === sourceLayerId); + if (!document || !source || document.layers.some((candidate) => candidate.id === layer.id)) { + return false; + } + const captured = o.captureCache(source, document); + if (captured === 'not-ready') { + return false; + } + const selectedLayerId = document.selectedLayerId; + const apply = (): void => { + const prepared = captured ? o.preparePixels(layer.id, captured.rect, captured.pixels) : null; + o.dispatchPrepared( + { + add: { index, layers: [layer] }, + enabledUpdates: [], + selectedLayerId: layer.id, + type: 'applyCanvasLayerStackMutation', + }, + () => + o.getReducerDocument()?.selectedLayerId === layer.id && + o.getReducerDocument()?.layers.some((candidate) => candidate === layer) === true, + () => + o.getDocument()?.selectedLayerId === layer.id && + o.getDocument()?.layers.some((candidate) => candidate === layer) === true + ); + if (prepared) { + o.installPrepared(prepared, o.needsPixelPersistence(layer)); + } + }; + apply(); + o.history.push({ + bytes: captured ? captured.rect.width * captured.rect.height * 4 + 256 : 256, + label, + redo: apply, + replayFailureAtomic: true, + undo: () => + o.dispatchPrepared( + { enabledUpdates: [], removeIds: [layer.id], selectedLayerId, type: 'applyCanvasLayerStackMutation' }, + () => + o.getReducerDocument()?.selectedLayerId === selectedLayerId && + o.getReducerDocument()?.layers.some((candidate) => candidate.id === layer.id) === false, + () => + o.getDocument()?.selectedLayerId === selectedLayerId && + o.getDocument()?.layers.some((candidate) => candidate.id === layer.id) === false + ), + }); + return true; + } + + convert(label: string, expected: CanvasLayerContract, after: CanvasLayerContract): boolean { + const o = this.options; + if (!o.canEdit() || o.isGestureActive() || expected.id !== after.id || expected.type === after.type) { + return false; + } + o.endBurst(); + const document = o.getDocument(); + const current = document?.layers.find((candidate) => candidate.id === expected.id); + if (!document || !current || current !== expected || current.isLocked || current.type !== expected.type) { + return false; + } + const captured = o.captureCache(current, document); + if (captured === 'not-ready') { + return false; + } + const apply = (layer: CanvasLayerContract): void => { + const prepared = captured ? o.preparePixels(layer.id, captured.rect, captured.pixels) : null; + o.dispatchPrepared( + { id: layer.id, layer, targetType: layer.type, type: 'convertCanvasLayer' }, + () => o.sameContract(o.getReducerDocument(), layer), + () => o.sameContract(o.getDocument(), layer) + ); + try { + o.discardPersisted(layer.id); + } catch { + /* Ancillary after reducer acceptance. */ + } + if (prepared) { + o.installPrepared(prepared, o.needsPixelPersistence(layer)); + } + }; + const before = structuredClone(current); + apply(after); + o.history.push({ + bytes: captured ? captured.rect.width * captured.rect.height * 4 + 256 : 256, + label, + redo: () => apply(after), + replayFailureAtomic: true, + undo: () => apply(before), + }); + return true; + } + + dispose(): void {} +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/maskLayerController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/maskLayerController.ts new file mode 100644 index 00000000000..66859a118ac --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/maskLayerController.ts @@ -0,0 +1,140 @@ +import type { History } from '@workbench/canvas-engine/history/history'; +import type { ImagePatchApply } from '@workbench/canvas-engine/history/imagePatch'; +import type { LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasImageRef, CanvasLayerContract } from '@workbench/types'; + +import { getSourceContentRect, isMaskLayer } from '@workbench/canvas-engine/document/sources'; +import { createImagePatchEntry } from '@workbench/canvas-engine/history/imagePatch'; +import { invert as invertMatrix } from '@workbench/canvas-engine/math/mat2d'; +import { isEmpty, roundOut, transformBounds, union } from '@workbench/canvas-engine/math/rect'; +import { bakeMatrix } from '@workbench/canvas-engine/transform/transformMath'; + +export interface MaskLayerControllerOptions { + readonly layers: LayerCacheStore; + readonly history: History; + readonly applyImagePatch: ImagePatchApply; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly canEdit: () => boolean; + readonly isGestureActive: () => boolean; + readonly isCacheReady: (layer: CanvasLayerContract, document: CanvasDocumentContractV2) => boolean; + readonly endBurst: () => void; + readonly discardPersisted: (layerId: string) => void; + readonly markDirty: (layerId: string) => void; + readonly dispatch: (action: CanvasProjectMutation) => void; + readonly deleteDerived: (layerId: string) => void; + readonly notifyPainted: (layerId: string) => void; + readonly restoreCache: (layerId: string, rect: Rect, pixels: ImageData) => void; +} + +/** Owns destructive mask pixel operations and their history/persistence lifecycle. */ +export class MaskLayerController { + private disposed = false; + + constructor(private readonly deps: MaskLayerControllerOptions) {} + + clear(layerId: string): boolean { + if (this.disposed || !this.deps.canEdit() || this.deps.isGestureActive()) { + return false; + } + const document = this.deps.getDocument(); + const layer = document?.layers.find((candidate) => candidate.id === layerId); + if (!document || !layer || !isMaskLayer(layer) || layer.isLocked) { + return false; + } + const originalBitmap = layer.mask.bitmap; + const originalOffset = layer.mask.offset ?? { x: 0, y: 0 }; + const entry = this.deps.isCacheReady(layer, document) ? this.deps.layers.get(layerId) : undefined; + const rect = entry && !isEmpty(entry.rect) ? { ...entry.rect } : null; + const before = rect ? entry?.surface.ctx.getImageData(0, 0, rect.width, rect.height) : null; + if (!originalBitmap && (!before || !rect)) { + return false; + } + this.deps.endBurst(); + const dispatchMask = (bitmap: CanvasImageRef | null, offset: { x: number; y: number }): void => { + this.deps.dispatch({ + config: { layerType: layer.type, mask: { bitmap, offset } }, + id: layerId, + type: 'updateCanvasLayerConfig', + }); + }; + const applyClear = (): void => { + this.deps.discardPersisted(layerId); + dispatchMask(null, { x: 0, y: 0 }); + this.deps.layers.delete(layerId); + this.deps.deleteDerived(layerId); + const empty = this.deps.layers.getOrCreateRect(layerId, { height: 0, width: 0, x: 0, y: 0 }); + empty.stale = false; + this.deps.notifyPainted(layerId); + }; + const applyRestore = (): void => { + this.deps.discardPersisted(layerId); + dispatchMask(originalBitmap, originalOffset); + if (before && rect) { + this.deps.restoreCache(layerId, rect, before); + } + }; + applyClear(); + this.deps.history.push({ + bytes: (before?.data.byteLength ?? 0) + 256, + label: 'Clear mask', + redo: applyClear, + undo: applyRestore, + }); + return true; + } + + invert(layerId: string): boolean { + if (this.disposed || !this.deps.canEdit() || this.deps.isGestureActive()) { + return false; + } + const document = this.deps.getDocument(); + const layer = document?.layers.find((candidate) => candidate.id === layerId); + if (!document || !layer || !isMaskLayer(layer) || layer.isLocked || !layer.isEnabled) { + return false; + } + if (!this.deps.isCacheReady(layer, document)) { + return false; + } + const content = getSourceContentRect(layer, document); + const liveRect = this.deps.layers.get(layerId)?.rect; + const contentUnion = + liveRect && !isEmpty(liveRect) ? (isEmpty(content) ? liveRect : union(content, liveRect)) : content; + const inverse = invertMatrix(bakeMatrix(layer.transform)); + const bbox = inverse ? roundOut(transformBounds(inverse, document.bbox)) : document.bbox; + const domain = roundOut(isEmpty(contentUnion) ? bbox : union(contentUnion, bbox)); + if (isEmpty(domain)) { + return false; + } + this.deps.endBurst(); + const entry = this.deps.layers.growToRect(layerId, domain); + const readX = domain.x - entry.rect.x; + const readY = domain.y - entry.rect.y; + const before = entry.surface.ctx.getImageData(readX, readY, domain.width, domain.height); + const work = entry.surface.ctx.getImageData(readX, readY, domain.width, domain.height); + for (let index = 3; index < work.data.length; index += 4) { + work.data[index] = 255 - (work.data[index] ?? 0); + } + entry.surface.ctx.putImageData(work, readX, readY); + this.deps.notifyPainted(layerId); + this.deps.markDirty(layerId); + if (!this.deps.history.isApplying()) { + this.deps.history.push( + createImagePatchEntry({ + after: work, + apply: this.deps.applyImagePatch, + before, + label: 'Invert mask', + layerId, + rect: domain, + }) + ); + } + return true; + } + + dispose(): void { + this.disposed = true; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/maskResultController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/maskResultController.ts new file mode 100644 index 00000000000..d884b932d14 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/maskResultController.ts @@ -0,0 +1,137 @@ +import type { LayerExportGuard } from '@workbench/canvas-engine/api'; +import type { History } from '@workbench/canvas-engine/history/history'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasImageRef } from '@workbench/types'; + +import { + createInpaintMaskFromImage, + createRegionalGuidanceFromImage, + DEFAULT_INPAINT_MASK_FILL, + nextInpaintMaskName, + nextRegionalGuidanceFillColor, + nextRegionalGuidanceName, +} from '@workbench/canvas-engine/document/layerFactories'; + +export type MaskImageResultTarget = 'inpaint_mask' | 'regional_guidance'; +export interface CommitMaskImageResultOptions { + guard: LayerExportGuard; + image: CanvasImageRef; + rect: Rect; + target: MaskImageResultTarget; + signal?: AbortSignal; +} +export type CommitMaskImageResult = + | { status: 'committed'; layerId: string } + | { status: 'aborted' | 'missing' | 'locked' | 'stale' | 'unsupported' | 'busy' }; + +export interface MaskResultControllerOptions { + readonly canEdit: (owner?: Owner) => boolean; + readonly createLayerId: () => string; + readonly dispatchPrepared: ( + action: CanvasProjectMutation, + reducerAccepted: () => boolean, + mirrorAccepted: () => boolean + ) => void; + readonly endBurst: () => void; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly getReducerDocument: () => CanvasDocumentContractV2 | null; + readonly history: History; + readonly isGestureActive: () => boolean; + readonly isGuardCurrent: (guard: LayerExportGuard) => boolean; +} + +/** Converts a guarded object-selection result into a structural mask layer. */ +export class MaskResultController { + constructor(private readonly options: MaskResultControllerOptions) {} + + commit(options: CommitMaskImageResultOptions, owner?: Owner): Promise { + const o = this.options; + if (!o.canEdit(owner)) { + return Promise.resolve({ status: 'busy' }); + } + if (options.signal?.aborted) { + return Promise.resolve({ status: 'aborted' }); + } + const document = o.getDocument(); + if (!document) { + return Promise.resolve({ status: 'missing' }); + } + const liveLayer = document.layers.find((candidate) => candidate.id === options.guard.layerId); + if (!liveLayer) { + return Promise.resolve({ status: 'missing' }); + } + if (liveLayer.isLocked) { + return Promise.resolve({ status: 'locked' }); + } + if (liveLayer.type !== 'raster' && liveLayer.type !== 'control') { + return Promise.resolve({ status: 'unsupported' }); + } + const sourceIndex = document.layers.findIndex((candidate) => candidate.id === liveLayer.id); + if (o.isGestureActive()) { + return Promise.resolve({ status: 'busy' }); + } + if (!o.isGuardCurrent(options.guard)) { + return Promise.resolve({ status: 'stale' }); + } + if (options.signal?.aborted) { + return Promise.resolve({ status: 'aborted' }); + } + if (sourceIndex < 0) { + return Promise.resolve({ status: 'missing' }); + } + const names = document.layers.map((layer) => layer.name); + const layerId = o.createLayerId(); + const layer = + options.target === 'inpaint_mask' + ? createInpaintMaskFromImage({ + fill: DEFAULT_INPAINT_MASK_FILL, + id: layerId, + image: options.image, + name: nextInpaintMaskName(names), + rect: options.rect, + }) + : createRegionalGuidanceFromImage({ + fill: { + color: nextRegionalGuidanceFillColor( + document.layers.filter((candidate) => candidate.type === 'regional_guidance').length + ), + style: 'solid', + }, + id: layerId, + image: options.image, + name: nextRegionalGuidanceName(names), + rect: options.rect, + }); + const selectedLayerId = document.selectedLayerId; + const apply = (): void => + o.dispatchPrepared( + { index: sourceIndex, layer, type: 'addCanvasLayer' }, + () => o.getReducerDocument()?.layers.some((candidate) => candidate === layer) === true, + () => o.getDocument()?.layers.some((candidate) => candidate === layer) === true + ); + o.endBurst(); + apply(); + o.history.push({ + bytes: 256, + label: options.target === 'inpaint_mask' ? 'Create inpaint mask from object' : 'Create region from object', + redo: apply, + replayFailureAtomic: true, + undo: () => { + o.dispatchPrepared( + { id: selectedLayerId, type: 'setCanvasSelectedLayer' }, + () => o.getReducerDocument()?.selectedLayerId === selectedLayerId, + () => o.getDocument()?.selectedLayerId === selectedLayerId + ); + o.dispatchPrepared( + { ids: [layerId], type: 'removeCanvasLayers' }, + () => o.getReducerDocument()?.layers.some((candidate) => candidate.id === layerId) === false, + () => o.getDocument()?.layers.some((candidate) => candidate.id === layerId) === false + ); + }, + }); + return Promise.resolve({ layerId, status: 'committed' }); + } + + dispose(): void {} +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.ts new file mode 100644 index 00000000000..2fcd2e08c3e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.ts @@ -0,0 +1,285 @@ +import type { LayerExportGuard } from '@workbench/canvas-engine/api'; +import type { History } from '@workbench/canvas-engine/history/history'; +import type { LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { mergeDownMatrix } from '@workbench/canvas-engine/document/mergeDown'; +import { getMergeVisibleRasterLayers } from '@workbench/canvas-engine/document/mergeVisible'; +import { isMergeableRasterLayer } from '@workbench/canvas-engine/document/sources'; +import { isEmpty, roundOut, transformBounds, union } from '@workbench/canvas-engine/math/rect'; +import { blendToComposite } from '@workbench/canvas-engine/render/compositor'; + +export type MergeVisibleResult = 'merged' | 'not-ready' | 'busy' | 'nothing'; + +type ExportResult = + | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' }; + +export interface MergeLayerControllerOptions { + readonly backend: RasterBackend; + readonly history: History; + readonly layers: LayerCacheStore; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly getReducerDocument: () => CanvasDocumentContractV2 | null; + readonly canEdit: () => boolean; + readonly capturePermit: () => object | null; + readonly isPermitCurrent: (permit: object) => boolean; + readonly isGestureActive: () => boolean; + readonly isCacheReady: (layer: CanvasLayerContract, document: CanvasDocumentContractV2) => boolean; + readonly hasExportableContent: (layerId: string) => boolean; + readonly exportBaked: (layerId: string) => Promise; + readonly isGuardCurrent: (guard: LayerExportGuard) => boolean; + readonly createLayerId: () => string; + readonly preparePixels: (layerId: string, rect: Rect, pixels: RasterSurface) => unknown; + readonly installPrepared: (prepared: unknown) => void; + readonly dispatchPrepared: ( + action: CanvasProjectMutation, + expectedReducer: () => boolean, + expectedMirror: () => boolean + ) => void; + readonly endBurst: () => void; + readonly dispatch: (action: CanvasProjectMutation) => void; + readonly notifyPainted: (layerId: string) => void; + readonly markDirty: (layerId: string) => void; +} + +/** Owns destructive merge-down and non-destructive merge-visible pixel operations. */ +export class MergeLayerController { + private disposed = false; + + constructor(private readonly deps: MergeLayerControllerOptions) {} + + mergeDown(upperLayerId: string): boolean { + if (this.disposed || !this.deps.canEdit() || this.deps.isGestureActive()) { + return false; + } + this.deps.endBurst(); + const document = this.deps.getDocument(); + if (!document) { + return false; + } + const upperIndex = document.layers.findIndex((layer) => layer.id === upperLayerId); + const upper = document.layers[upperIndex]; + const below = document.layers[upperIndex + 1]; + if (upperIndex < 0 || !upper || !below || !isMergeableRasterLayer(upper) || !isMergeableRasterLayer(below)) { + return false; + } + const upperCache = this.deps.layers.get(upper.id); + const belowCache = this.deps.layers.get(below.id); + const upperHasContent = this.deps.hasExportableContent(upper.id); + const belowHasContent = this.deps.hasExportableContent(below.id); + if ( + (upperHasContent && !upperCache) || + (belowHasContent && !belowCache) || + !this.deps.isCacheReady(upper, document) || + !this.deps.isCacheReady(below, document) + ) { + return false; + } + const matrix = mergeDownMatrix(below.transform, upper.transform); + if (!matrix) { + return false; + } + if (!belowHasContent && !upperHasContent) { + this.deps.dispatch({ + source: { bitmap: null, offset: { x: 0, y: 0 }, type: 'paint' }, + type: 'mergeCanvasLayersDown', + upperLayerId, + }); + this.deps.layers.delete(below.id); + this.deps.notifyPainted(below.id); + this.deps.markDirty(below.id); + return true; + } + const mergedRect = roundOut( + belowHasContent && upperHasContent + ? union(belowCache!.rect, transformBounds(matrix, upperCache!.rect)) + : belowHasContent + ? belowCache!.rect + : transformBounds(matrix, upperCache!.rect) + ); + const merged = this.deps.backend.createSurface(mergedRect.width, mergedRect.height); + const context = merged.ctx; + context.setTransform(1, 0, 0, 1, 0, 0); + context.clearRect(0, 0, mergedRect.width, mergedRect.height); + if (belowHasContent) { + context.drawImage( + belowCache!.surface.canvas, + belowCache!.rect.x - mergedRect.x, + belowCache!.rect.y - mergedRect.y + ); + } + if (upperHasContent) { + context.setTransform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e - mergedRect.x, matrix.f - mergedRect.y); + context.globalAlpha = upper.opacity; + context.globalCompositeOperation = blendToComposite(upper.blendMode); + context.drawImage(upperCache!.surface.canvas, upperCache!.rect.x, upperCache!.rect.y); + context.setTransform(1, 0, 0, 1, 0, 0); + context.globalAlpha = 1; + context.globalCompositeOperation = 'source-over'; + } + this.deps.dispatch({ + source: { bitmap: null, offset: { x: mergedRect.x, y: mergedRect.y }, type: 'paint' }, + type: 'mergeCanvasLayersDown', + upperLayerId, + }); + this.deps.layers.delete(below.id); + const target = this.deps.layers.getOrCreateRect(below.id, mergedRect); + target.surface.ctx.drawImage(merged.canvas, 0, 0); + target.stale = false; + this.deps.notifyPainted(below.id); + this.deps.markDirty(below.id); + return true; + } + + async mergeVisible(): Promise { + const permit = this.deps.capturePermit(); + if (this.disposed || !permit || !this.deps.canEdit() || this.deps.isGestureActive()) { + return 'busy'; + } + this.deps.endBurst(); + const document = this.deps.getDocument(); + if (!document) { + return 'nothing'; + } + const contributors = getMergeVisibleRasterLayers(document.layers, this.deps.hasExportableContent); + if (contributors.length < 2) { + return 'nothing'; + } + const owned: Extract[] = []; + const acquire = async (layerId: string): Promise => { + const result = await this.deps.exportBaked(layerId); + if (result.status === 'ok') { + owned.push(result); + } + return result; + }; + try { + const settled = await Promise.allSettled(contributors.map((layer) => acquire(layer.id))); + const rejected = settled.find((result) => result.status === 'rejected'); + if (rejected?.status === 'rejected') { + throw rejected.reason instanceof Error ? rejected.reason : new Error(String(rejected.reason)); + } + const exports = settled.map((result) => (result as PromiseFulfilledResult).value); + if (!this.deps.isPermitCurrent(permit)) { + return 'busy'; + } + if (exports.some((result) => result.status !== 'ok')) { + return 'not-ready'; + } + if (this.deps.isGestureActive()) { + return 'busy'; + } + for (let index = 0; index < exports.length; index += 1) { + const exported = exports[index]; + const contributor = contributors[index]; + if ( + !exported || + exported.status !== 'ok' || + !contributor || + exported.guard.layer !== contributor || + !this.deps.isGuardCurrent(exported.guard) + ) { + return 'not-ready'; + } + } + + const liveDocument = this.deps.getDocument(); + const liveContributors = liveDocument + ? getMergeVisibleRasterLayers(liveDocument.layers, this.deps.hasExportableContent) + : []; + if ( + !liveDocument || + liveContributors.length !== contributors.length || + liveContributors.some((layer, index) => layer !== contributors[index]) + ) { + return 'not-ready'; + } + + const successful = exports as Extract[]; + let rect = successful[0]!.rect; + for (let index = 1; index < successful.length; index += 1) { + rect = union(rect, successful[index]!.rect); + } + rect = roundOut(rect); + if (isEmpty(rect)) { + return 'nothing'; + } + const pixels = this.deps.backend.createSurface(rect.width, rect.height); + const context = pixels.ctx; + context.setTransform(1, 0, 0, 1, 0, 0); + context.clearRect(0, 0, rect.width, rect.height); + for (let index = successful.length - 1; index >= 0; index -= 1) { + const exported = successful[index]!; + const contributor = contributors[index]!; + context.globalAlpha = contributor.opacity; + context.globalCompositeOperation = blendToComposite(contributor.blendMode); + context.drawImage(exported.surface.canvas, exported.rect.x - rect.x, exported.rect.y - rect.y); + } + context.globalAlpha = 1; + context.globalCompositeOperation = 'source-over'; + + const resultId = this.deps.createLayerId(); + const resultLayer: CanvasLayerContract = { + blendMode: 'normal', + id: resultId, + isEnabled: true, + isLocked: false, + name: `${contributors[0]!.name} merged`, + opacity: 1, + source: { bitmap: null, offset: { x: rect.x, y: rect.y }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }; + const selectedLayerId = liveDocument.selectedLayerId; + const hasResult = (doc: CanvasDocumentContractV2 | null): boolean => + doc?.selectedLayerId === resultId && doc.layers[0] === resultLayer; + const apply = (): void => { + const prepared = this.deps.preparePixels(resultId, rect, pixels); + this.deps.dispatchPrepared( + { + add: { index: 0, layers: [resultLayer] }, + enabledUpdates: [], + selectedLayerId: resultId, + type: 'applyCanvasLayerStackMutation', + }, + () => hasResult(this.deps.getReducerDocument()), + () => hasResult(this.deps.getDocument()) + ); + this.deps.installPrepared(prepared); + }; + if (!this.deps.isPermitCurrent(permit)) { + return 'busy'; + } + apply(); + this.deps.history.push({ + bytes: rect.width * rect.height * 4 + 256, + label: 'Merge visible', + redo: apply, + replayFailureAtomic: true, + undo: () => + this.deps.dispatchPrepared( + { enabledUpdates: [], removeIds: [resultId], selectedLayerId, type: 'applyCanvasLayerStackMutation' }, + () => + this.deps.getReducerDocument()?.selectedLayerId === selectedLayerId && + this.deps.getReducerDocument()?.layers.some((layer) => layer.id === resultId) === false, + () => + this.deps.getDocument()?.selectedLayerId === selectedLayerId && + this.deps.getDocument()?.layers.some((layer) => layer.id === resultId) === false + ), + }); + return 'merged'; + } finally { + for (const result of owned) { + result.release(); + } + } + } + + dispose(): void { + this.disposed = true; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/persistenceController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/persistenceController.test.ts new file mode 100644 index 00000000000..aebfee5c2ab --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/persistenceController.test.ts @@ -0,0 +1,27 @@ +import type { BitmapStore } from '@workbench/canvas-engine/document/bitmapStore'; + +import { describe, expect, it, vi } from 'vitest'; + +import { PersistenceController } from './persistenceController'; + +const createStore = (): BitmapStore => ({ + discardLayer: vi.fn(), + dispose: vi.fn(), + flushPendingUploads: vi.fn(() => Promise.resolve()), + isSelfEcho: vi.fn(() => false), + markLayerDirty: vi.fn(), + reset: vi.fn(), + suspendLayer: vi.fn(() => vi.fn()), +}); + +describe('PersistenceController', () => { + it('provides a flush barrier and idempotent disposal', async () => { + const store = createStore(); + const controller = new PersistenceController(store); + await controller.flush(); + expect(store.flushPendingUploads).toHaveBeenCalledOnce(); + controller.dispose(); + controller.dispose(); + expect(store.dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/persistenceController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/persistenceController.ts new file mode 100644 index 00000000000..8193ed12db2 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/persistenceController.ts @@ -0,0 +1,22 @@ +import type { BitmapStore } from '@workbench/canvas-engine/document/bitmapStore'; + +export class PersistenceController { + readonly store: BitmapStore; + private disposed = false; + + constructor(store: BitmapStore) { + this.store = store; + } + + flush(): Promise { + return this.store.flushPendingUploads(); + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.store.dispose(); + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/previewStateController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/previewStateController.ts new file mode 100644 index 00000000000..25421a62d6a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/previewStateController.ts @@ -0,0 +1,120 @@ +import type { LayerExportGuard } from '@workbench/canvas-engine/api'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; + +export interface StagedPreviewState { + surface: RasterSurface; + width: number; + height: number; + placement?: Rect & { opacity: number }; +} + +export interface FilterPreviewState { + surface: RasterSurface; + rect: Rect; + guard: LayerExportGuard; +} + +export interface SamPreviewState { + data: RasterSurface; + rect: Rect; + guard: LayerExportGuard; + isolated: boolean; +} + +/** Owns render-preview surfaces and monotonic async publication guards. */ +export class PreviewStateController { + private staged: StagedPreviewState | null = null; + private stagedToken = 0; + private readonly filters = new Map(); + private readonly filterTokens = new Map(); + private readonly guardedFilterTokens = new Map(); + private sam: SamPreviewState | null = null; + + getStaged(): StagedPreviewState | null { + return this.staged; + } + nextStagedToken(): number { + return ++this.stagedToken; + } + isStagedTokenCurrent(token: number): boolean { + return token === this.stagedToken; + } + publishStaged(token: number, preview: StagedPreviewState): boolean { + if (!this.isStagedTokenCurrent(token)) { + return false; + } + this.staged = preview; + return true; + } + clearStaged(): boolean { + this.stagedToken += 1; + const hadPreview = this.staged !== null; + this.staged = null; + return hadPreview; + } + + hasFilter(layerId: string): boolean { + return this.filters.has(layerId); + } + getFilter(layerId: string): FilterPreviewState | undefined { + return this.filters.get(layerId); + } + filterSnapshot(): Map { + return new Map(this.filters); + } + filterLayerIds(): string[] { + return [...new Set([...this.filters.keys(), ...this.filterTokens.keys(), ...this.guardedFilterTokens.keys()])]; + } + clearFilter(layerId: string): boolean { + this.filterTokens.set(layerId, (this.filterTokens.get(layerId) ?? 0) + 1); + this.guardedFilterTokens.delete(layerId); + return this.filters.delete(layerId); + } + beginGuardedFilter(layerId: string): number { + const token = (this.filterTokens.get(layerId) ?? 0) + 1; + this.filterTokens.set(layerId, token); + this.guardedFilterTokens.set(layerId, token); + return token; + } + finishGuardedFilter(layerId: string, token: number): void { + if (this.guardedFilterTokens.get(layerId) === token) { + this.guardedFilterTokens.delete(layerId); + } + } + isFilterTokenCurrent(layerId: string, token: number): boolean { + return this.filterTokens.get(layerId) === token; + } + publishFilter(layerId: string, token: number, preview: FilterPreviewState): boolean { + if (!this.isFilterTokenCurrent(layerId, token)) { + return false; + } + this.filters.set(layerId, preview); + return true; + } + clearFilters(): void { + for (const layerId of this.filterLayerIds()) { + this.clearFilter(layerId); + } + } + + getSam(): SamPreviewState | null { + return this.sam; + } + setSam(preview: SamPreviewState): SamPreviewState | null { + const previous = this.sam; + this.sam = preview; + return previous; + } + clearSam(): SamPreviewState | null { + const previous = this.sam; + this.sam = null; + return previous; + } + + dispose(): void { + this.clearStaged(); + this.clearFilters(); + this.sam = null; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/psdExportController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/psdExportController.test.ts new file mode 100644 index 00000000000..577fc3f083a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/psdExportController.test.ts @@ -0,0 +1,403 @@ +import type { CanvasDocumentSnapshot, CanvasRasterSnapshot } from '@workbench/canvas-engine/api'; +import type { ExecutePsdExportDeps, PsdExportPlan } from '@workbench/canvas-engine/export/psdExport'; +import type { CanvasDocumentContractV2, CanvasRasterLayerContractV2, CanvasStateContractV2 } from '@workbench/types'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it, vi } from 'vitest'; + +import { derivePsdPixelAreaLimit, PSD_ALLOCATION_BYTES_PER_PIXEL, PsdExportController } from './psdExportController'; + +const document: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'normal', + id: 'layer', + isEnabled: true, + isLocked: false, + name: 'Layer', + opacity: 1, + source: { image: { height: 100, imageName: 'layer.png', width: 100 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + selectedLayerId: null, + version: 2, + width: 100, +}; + +const canvas: CanvasStateContractV2 = { + document, + documentRevision: 0, + snapshots: [], + stagingArea: { + areThumbnailsVisible: false, + autoSwitchMode: 'off', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + version: 2, +}; + +const canvasWithDocument = (nextDocument: CanvasDocumentContractV2): CanvasStateContractV2 => ({ + ...canvas, + document: nextDocument, +}); + +describe('PsdExportController', () => { + it('derives the PSD allocation pixel-area limit from currently available reserved bytes', () => { + expect(derivePsdPixelAreaLimit(159_999)).toBe(19_999); + expect(derivePsdPixelAreaLimit(160_000)).toBe(20_000); + }); + + it('executes from one immutable raster snapshot instead of live layer pixels', async () => { + const backend = createTestStubRasterBackend(); + const detached = backend.createSurface(100, 100); + const documentSnapshot: CanvasDocumentSnapshot = { + canvas, + documentGeneration: 4, + }; + const release = vi.fn(); + const rasterSnapshot: CanvasRasterSnapshot = { + ...documentSnapshot, + emptyLayerIds: new Set(), + layerSurfaces: new Map([['layer', { rect: { height: 100, width: 100, x: 0, y: 0 }, surface: detached }]]), + release, + }; + const captureRasterSnapshot = vi.fn(() => Promise.resolve({ snapshot: rasterSnapshot, status: 'ok' as const })); + const execute = vi.fn(async (_plan, _fileName, deps: ExecutePsdExportDeps) => { + await expect(deps.getLayerSurface('layer')).resolves.toEqual({ + rect: { height: 100, width: 100, x: 0, y: 0 }, + surface: detached, + }); + }); + const controller = new PsdExportController({ + backend, + captureDocumentSnapshot: () => documentSnapshot, + captureRasterSnapshot, + execute, + getAvailableBytes: () => 1_000_000, + isDocumentSnapshotCurrent: () => true, + reserve: () => ({ lease: { release: vi.fn() }, status: 'ok' }), + }); + + await expect(controller.export('layers.psd')).resolves.toBe('exported'); + expect(captureRasterSnapshot).toHaveBeenCalledWith(documentSnapshot, ['layer'], expect.any(Object)); + expect(execute).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledOnce(); + }); + + it('plans a new bitmap-less paint layer from its captured unflushed live pixels', async () => { + const backend = createTestStubRasterBackend(); + const paintDocument: CanvasDocumentContractV2 = { + ...document, + layers: [ + { + blendMode: 'normal', + id: 'paint', + isEnabled: true, + isLocked: false, + name: 'Paint', + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + }; + const paintCanvas = canvasWithDocument(paintDocument); + const documentSnapshot: CanvasDocumentSnapshot = { canvas: paintCanvas, documentGeneration: 6 }; + const rect = { height: 30, width: 20, x: -5, y: -6 }; + const execute = vi.fn((plan) => { + expect(plan).toMatchObject({ height: 30, status: 'ok', width: 20 }); + if (plan.status !== 'ok') { + throw new Error('Expected an executable PSD plan'); + } + expect(plan.layers[0]?.contentRect).toEqual(rect); + return Promise.resolve(); + }); + const controller = new PsdExportController({ + backend, + captureDocumentSnapshot: () => documentSnapshot, + captureRasterSnapshot: () => + Promise.resolve({ + snapshot: { + ...documentSnapshot, + emptyLayerIds: new Set(), + layerSurfaces: new Map([['paint', { rect, surface: backend.createSurface(20, 30) }]]), + release: vi.fn(), + }, + status: 'ok', + }), + execute, + getAvailableBytes: () => 1_000_000, + isDocumentSnapshotCurrent: () => true, + reserve: () => ({ lease: { release: vi.fn() }, status: 'ok' }), + }); + + await expect(controller.export('paint.psd')).resolves.toBe('exported'); + expect(execute).toHaveBeenCalledOnce(); + }); + + it('plans and reserves from a grown live-cache rect instead of smaller persisted source bounds', async () => { + const backend = createTestStubRasterBackend(); + const smallLayer: CanvasRasterLayerContractV2 = { + ...(document.layers[0] as CanvasRasterLayerContractV2), + source: { image: { height: 10, imageName: 'small.png', width: 10 }, type: 'image' }, + }; + const smallDocument: CanvasDocumentContractV2 = { + ...document, + layers: [smallLayer], + }; + const grownCanvas = canvasWithDocument(smallDocument); + const documentSnapshot: CanvasDocumentSnapshot = { canvas: grownCanvas, documentGeneration: 7 }; + const grownRect = { height: 40, width: 30, x: -12, y: -8 }; + const reserve = vi.fn(() => ({ lease: { release: vi.fn() }, status: 'ok' as const })); + const execute = vi.fn((plan) => { + expect(plan).toMatchObject({ height: 40, status: 'ok', width: 30 }); + if (plan.status !== 'ok') { + throw new Error('Expected an executable PSD plan'); + } + expect(plan.layers[0]?.contentRect).toEqual(grownRect); + return Promise.resolve(); + }); + const controller = new PsdExportController({ + backend, + captureDocumentSnapshot: () => documentSnapshot, + captureRasterSnapshot: () => + Promise.resolve({ + snapshot: { + ...documentSnapshot, + emptyLayerIds: new Set(), + layerSurfaces: new Map([['layer', { rect: grownRect, surface: backend.createSurface(30, 40) }]]), + release: vi.fn(), + }, + status: 'ok', + }), + execute, + getAvailableBytes: () => 1_000_000, + isDocumentSnapshotCurrent: () => true, + reserve, + }); + + await expect(controller.export('grown.psd')).resolves.toBe('exported'); + expect(reserve).toHaveBeenCalledWith(30 * 40 * 2 * PSD_ALLOCATION_BYTES_PER_PIXEL); + }); + + it('exports valid captured pixels while skipping a confirmed-empty paint layer', async () => { + const backend = createTestStubRasterBackend(); + const validLayer = document.layers[0] as CanvasRasterLayerContractV2; + const blankLayer: CanvasRasterLayerContractV2 = { + ...validLayer, + id: 'blank', + name: 'Blank', + source: { bitmap: null, type: 'paint' }, + }; + const mixedCanvas = canvasWithDocument({ ...document, layers: [validLayer, blankLayer] }); + const documentSnapshot: CanvasDocumentSnapshot = { canvas: mixedCanvas, documentGeneration: 8 }; + const execute = vi.fn((plan: PsdExportPlan) => { + if (plan.status !== 'ok') { + throw new Error('Expected an executable PSD plan'); + } + expect(plan.layers.map((layer) => layer.id)).toEqual(['layer']); + return Promise.resolve(); + }); + const controller = new PsdExportController({ + backend, + captureDocumentSnapshot: () => documentSnapshot, + captureRasterSnapshot: () => + Promise.resolve({ + snapshot: { + ...documentSnapshot, + emptyLayerIds: new Set(['blank']), + layerSurfaces: new Map([ + [ + 'layer', + { + rect: { height: 100, width: 100, x: 0, y: 0 }, + surface: backend.createSurface(100, 100), + }, + ], + ]), + release: vi.fn(), + }, + status: 'ok', + }), + execute, + getAvailableBytes: () => 1_000_000, + isDocumentSnapshotCurrent: () => true, + reserve: () => ({ lease: { release: vi.fn() }, status: 'ok' }), + }); + + await expect(controller.export('mixed.psd')).resolves.toBe('exported'); + expect(execute).toHaveBeenCalledOnce(); + }); + + it('returns nothing when every requested raster layer is confirmed empty', async () => { + const backend = createTestStubRasterBackend(); + const blankLayer: CanvasRasterLayerContractV2 = { + ...(document.layers[0] as CanvasRasterLayerContractV2), + id: 'blank', + name: 'Blank', + source: { bitmap: null, type: 'paint' }, + }; + const blankCanvas = canvasWithDocument({ ...document, layers: [blankLayer] }); + const documentSnapshot: CanvasDocumentSnapshot = { canvas: blankCanvas, documentGeneration: 9 }; + const release = vi.fn(); + const controller = new PsdExportController({ + backend, + captureDocumentSnapshot: () => documentSnapshot, + captureRasterSnapshot: () => + Promise.resolve({ + snapshot: { + ...documentSnapshot, + emptyLayerIds: new Set(['blank']), + layerSurfaces: new Map(), + release, + }, + status: 'ok', + }), + execute: vi.fn(), + getAvailableBytes: () => 1_000_000, + isDocumentSnapshotCurrent: () => true, + reserve: vi.fn(), + }); + + await expect(controller.export('blank.psd')).resolves.toBe('nothing'); + expect(release).toHaveBeenCalledOnce(); + }); + + it('derives the execution limit after detached capture and releases the snapshot when over budget', async () => { + const backend = createTestStubRasterBackend(); + const snapshotRelease = vi.fn(); + const reserve = vi.fn(); + const captureRasterSnapshot = vi.fn(() => + Promise.resolve({ + snapshot: { + canvas, + documentGeneration: 1, + emptyLayerIds: new Set(), + layerSurfaces: new Map([ + ['layer', { rect: { height: 100, width: 100, x: 0, y: 0 }, surface: backend.createSurface(100, 100) }], + ]), + release: snapshotRelease, + }, + status: 'ok' as const, + }) + ); + const controller = new PsdExportController({ + backend, + captureDocumentSnapshot: () => ({ + canvas, + documentGeneration: 1, + }), + captureRasterSnapshot, + getAvailableBytes: () => 0, + isDocumentSnapshotCurrent: () => true, + reserve, + }); + + await expect(controller.export('layers.psd')).resolves.toBe('over-budget'); + expect(captureRasterSnapshot).toHaveBeenCalledOnce(); + expect(snapshotRelease).toHaveBeenCalledOnce(); + expect(reserve).not.toHaveBeenCalled(); + }); + + it('propagates over-budget when immutable raster snapshot capture is refused', async () => { + const controller = new PsdExportController({ + backend: createTestStubRasterBackend(), + captureDocumentSnapshot: () => ({ + canvas, + documentGeneration: 1, + }), + captureRasterSnapshot: () => Promise.resolve({ status: 'over-budget' }), + getAvailableBytes: () => 1_000_000, + isDocumentSnapshotCurrent: () => true, + reserve: () => ({ lease: { release: vi.fn() }, status: 'ok' }), + }); + + await expect(controller.export('layers.psd')).resolves.toBe('over-budget'); + }); + + it('rejects a stale document generation after capture and releases the raster snapshot', async () => { + const backend = createTestStubRasterBackend(); + const documentSnapshot: CanvasDocumentSnapshot = { + canvas, + documentGeneration: 3, + }; + const release = vi.fn(); + const isDocumentSnapshotCurrent = vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false); + const controller = new PsdExportController({ + backend, + captureDocumentSnapshot: () => documentSnapshot, + captureRasterSnapshot: () => + Promise.resolve({ + snapshot: { + ...documentSnapshot, + emptyLayerIds: new Set(), + layerSurfaces: new Map(), + release, + }, + status: 'ok', + }), + execute: vi.fn(), + getAvailableBytes: () => 1_000_000, + isDocumentSnapshotCurrent, + reserve: vi.fn(), + }); + + await expect(controller.export('layers.psd')).resolves.toBe('stale'); + expect(release).toHaveBeenCalledOnce(); + }); + + it('aborts active execution on disposal and releases snapshot and reservation resources', async () => { + const backend = createTestStubRasterBackend(); + const documentSnapshot: CanvasDocumentSnapshot = { canvas, documentGeneration: 5 }; + const snapshotRelease = vi.fn(); + const reservationRelease = vi.fn(); + const controller = new PsdExportController({ + backend, + captureDocumentSnapshot: () => documentSnapshot, + captureRasterSnapshot: () => + Promise.resolve({ + snapshot: { + ...documentSnapshot, + emptyLayerIds: new Set(), + layerSurfaces: new Map([ + [ + 'layer', + { + rect: { height: 100, width: 100, x: 0, y: 0 }, + surface: backend.createSurface(100, 100), + }, + ], + ]), + release: snapshotRelease, + }, + status: 'ok', + }), + execute: (_plan, _fileName, deps) => + new Promise((_resolve, reject) => { + deps.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { + once: true, + }); + }), + getAvailableBytes: () => 1_000_000, + isDocumentSnapshotCurrent: () => true, + reserve: () => ({ lease: { release: reservationRelease }, status: 'ok' }), + }); + + const exportPromise = controller.export('layers.psd'); + await vi.waitFor(() => expect(snapshotRelease).not.toHaveBeenCalled()); + controller.dispose(); + + await expect(exportPromise).resolves.toBe('aborted'); + expect(snapshotRelease).toHaveBeenCalledOnce(); + expect(reservationRelease).toHaveBeenCalledOnce(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/psdExportController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/psdExportController.ts new file mode 100644 index 00000000000..416eb3ecbc4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/psdExportController.ts @@ -0,0 +1,188 @@ +import type { + CanvasDetachedLayerSurface, + CanvasDocumentSnapshot, + CaptureRasterSnapshotResult, + PsdExportResult, +} from '@workbench/canvas-engine/api'; +import type { RasterBackend } from '@workbench/canvas-engine/render/raster'; +import type { CanvasLayerContract } from '@workbench/types'; + +import { + executePsdExport, + planPsdExport, + type ExecutePsdExportDeps, + type PsdExportLayerInput, + type PsdExportPlan, +} from '@workbench/canvas-engine/export/psdExport'; + +type RasterMemoryReservationResult = + | { status: 'ok'; lease: { release(): void } } + | { status: 'over-budget'; requestedBytes: number; availableBytes: number }; + +export interface PsdExportControllerOptions { + readonly backend: RasterBackend; + readonly captureDocumentSnapshot: () => CanvasDocumentSnapshot | null; + readonly captureRasterSnapshot: ( + snapshot: CanvasDocumentSnapshot, + layerIds: readonly string[], + options: { signal: AbortSignal; includeDisabled: boolean } + ) => Promise; + readonly execute?: (plan: PsdExportPlan, fileName: string, deps: ExecutePsdExportDeps) => Promise; + readonly getAvailableBytes: () => number; + readonly isDocumentSnapshotCurrent: (snapshot: CanvasDocumentSnapshot) => boolean; + readonly reserve: (bytes: number) => RasterMemoryReservationResult; +} + +const isExportable = (layer: CanvasLayerContract): boolean => { + if (layer.type !== 'raster') { + return false; + } + switch (layer.source.type) { + case 'paint': + case 'image': + case 'gradient': + case 'text': + return true; + case 'shape': + return layer.source.kind !== 'polygon'; + default: + return false; + } +}; + +export const PSD_ALLOCATION_BYTES_PER_PIXEL = 8; + +export const derivePsdPixelAreaLimit = (availableBytes: number): number => + Math.max(0, Math.floor(availableBytes / PSD_ALLOCATION_BYTES_PER_PIXEL)); + +const getRequiredAllocationPixelArea = (plan: Extract): number => + plan.width * plan.height + + plan.layers.reduce((total, layer) => total + layer.worldRect.width * layer.worldRect.height, 0); + +/** Owns immutable PSD snapshot capture, budget reservation, execution, and cancellation. */ +export class PsdExportController { + private disposed = false; + private readonly active = new Set(); + + constructor(private readonly deps: PsdExportControllerOptions) {} + + async export(fileName: string, options: { signal?: AbortSignal } = {}): Promise { + if (this.disposed || options.signal?.aborted) { + return 'aborted'; + } + const abortController = new AbortController(); + const abort = (): void => abortController.abort(options.signal?.reason); + options.signal?.addEventListener('abort', abort, { once: true }); + this.active.add(abortController); + try { + const documentSnapshot = this.deps.captureDocumentSnapshot(); + if (!documentSnapshot) { + return 'nothing'; + } + if (!this.deps.isDocumentSnapshotCurrent(documentSnapshot)) { + return 'stale'; + } + const document = documentSnapshot.canvas.document; + const layers = document.layers.filter(isExportable); + if (layers.length === 0) { + return 'nothing'; + } + const capture = await this.deps.captureRasterSnapshot( + documentSnapshot, + layers.map((layer) => layer.id), + { includeDisabled: true, signal: abortController.signal } + ); + if (capture.status !== 'ok') { + return capture.status; + } + const rasterSnapshot = capture.snapshot; + try { + if (abortController.signal.aborted) { + return 'aborted'; + } + if (!this.deps.isDocumentSnapshotCurrent(documentSnapshot)) { + return 'stale'; + } + const inputs: PsdExportLayerInput[] = []; + for (const layer of layers) { + const detached = rasterSnapshot.layerSurfaces.get(layer.id); + if (!detached) { + if (rasterSnapshot.emptyLayerIds.has(layer.id)) { + continue; + } + return 'not-ready'; + } + inputs.push({ + adjustments: layer.type === 'raster' ? layer.adjustments : undefined, + blendMode: layer.blendMode, + contentRect: detached.rect, + id: layer.id, + isEnabled: layer.isEnabled, + name: layer.name, + opacity: layer.opacity, + transform: layer.transform, + }); + } + const plan = planPsdExport(inputs); + if (plan.status === 'empty') { + return 'nothing'; + } + if (plan.status === 'too-large') { + return 'too-large'; + } + const requestedPixelArea = getRequiredAllocationPixelArea(plan); + const requestedBytes = requestedPixelArea * PSD_ALLOCATION_BYTES_PER_PIXEL; + if (requestedPixelArea > derivePsdPixelAreaLimit(this.deps.getAvailableBytes())) { + return 'over-budget'; + } + const reservation = this.deps.reserve(requestedBytes); + if (reservation.status === 'over-budget') { + return 'over-budget'; + } + try { + const getLayerSurface = (layerId: string): Promise => { + const detached = rasterSnapshot.layerSurfaces.get(layerId); + if (!detached) { + return Promise.reject(new Error(`PSD raster snapshot is missing layer ${layerId}.`)); + } + return Promise.resolve(detached); + }; + try { + await (this.deps.execute ?? executePsdExport)( + plan, + /\.psd$/i.test(fileName) ? fileName : `${fileName}.psd`, + { backend: this.deps.backend, getLayerSurface, signal: abortController.signal } + ); + } catch (error) { + if (abortController.signal.aborted || (error instanceof DOMException && error.name === 'AbortError')) { + return 'aborted'; + } + throw error; + } + return abortController.signal.aborted ? 'aborted' : 'exported'; + } finally { + reservation.lease.release(); + } + } finally { + rasterSnapshot.release(); + } + } finally { + this.active.delete(abortController); + options.signal?.removeEventListener('abort', abort); + } + } + + cancel(): void { + for (const controller of this.active) { + controller.abort(); + } + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.cancel(); + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterController.test.ts new file mode 100644 index 00000000000..6f43351a185 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterController.test.ts @@ -0,0 +1,33 @@ +import type { CanvasRasterLayerContractV2 } from '@workbench/types'; + +import { createCanvasDiagnostics } from '@workbench/canvas-engine/diagnostics'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it, vi } from 'vitest'; + +import { RasterController } from './rasterController'; + +describe('RasterController', () => { + it('owns base and derived surfaces and invalidates all effects for a layer', () => { + const backend = createTestStubRasterBackend(); + const onVersionChange = vi.fn(); + const controller = new RasterController({ + backend, + diagnostics: createCanvasDiagnostics(true), + onVersionChange, + }); + const entry = controller.layers.getOrCreate('a', 10, 10); + const layer = { + adjustments: { brightness: 0.5, contrast: 0, saturation: 0 }, + id: 'a', + type: 'raster', + } as CanvasRasterLayerContractV2; + expect(controller.getAdjustedSurface(layer, entry)).not.toBeNull(); + expect(controller.derived.byteSize()).toBe(400); + + controller.deleteDerivedSurfaces('a'); + expect(controller.derived.byteSize()).toBe(0); + controller.dispose(); + controller.dispose(); + expect(controller.layers.byteSize()).toBe(0); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterController.ts new file mode 100644 index 00000000000..52bce8f99fd --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterController.ts @@ -0,0 +1,298 @@ +import type { CanvasDiagnostics } from '@workbench/canvas-engine/diagnostics'; +import type { LayerCacheEntry, LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { + CanvasDocumentContractV2, + CanvasImageRef, + CanvasLayerContract, + CanvasLayerSourceContract, +} from '@workbench/types'; + +export type DecodeImageResult = + | { status: 'ok'; surface: RasterSurface; decodedWidth: number; decodedHeight: number } + | { status: 'aborted' | 'stale' }; + +export interface RasterizationJob { + abortedByCaller?: boolean; + controller: AbortController; + version: number; + documentGeneration: number; + source: CanvasLayerSourceContract; + promise: Promise<'published' | 'stale' | 'error' | 'aborted'>; +} + +import { + createAdjustedSurfaceCache, + type AdjustedSurfaceCache, +} from '@workbench/canvas-engine/render/adjustedSurfaceCache'; +import { createDecodedBitmapPool, type DecodedBitmapPool } from '@workbench/canvas-engine/render/decodedBitmapPool'; +import { + createDerivedSurfaceCache, + type DerivedSurfaceCache, +} from '@workbench/canvas-engine/render/derivedSurfaceCache'; +import { createLayerCacheStore, DEFAULT_CACHE_BUDGET_BYTES } from '@workbench/canvas-engine/render/layerCache'; + +import { RasterMemoryBudgetController } from './rasterMemoryBudgetController'; + +export interface RasterControllerOptions { + readonly backend: RasterBackend; + readonly diagnostics: CanvasDiagnostics; + readonly onVersionChange?: (layerId: string) => void; + readonly getDocument?: () => CanvasDocumentContractV2 | null; + readonly getLayerImageName?: (layer: CanvasLayerContract) => string | null; + readonly imageResolver?: (imageName: string, signal?: AbortSignal) => Promise; +} + +export class RasterController { + readonly layers: LayerCacheStore; + readonly derived: DerivedSurfaceCache; + readonly adjustments: AdjustedSurfaceCache; + readonly memory: RasterMemoryBudgetController; + readonly bitmaps: DecodedBitmapPool; + private readonly jobs = new Map(); + private readonly activeJobs = new Set(); + private readonly trackedImages = new Map(); + private readonly mirroredImages = new Map(); + private readonly thumbnailKeys = new Map(); + private documentGeneration = 0; + private disposed = false; + private readonly getDocument: () => CanvasDocumentContractV2 | null; + private readonly getLayerImageName: (layer: CanvasLayerContract) => string | null; + private readonly backend: RasterBackend; + private readonly imageResolver: ((imageName: string, signal?: AbortSignal) => Promise) | null; + + constructor(options: RasterControllerOptions) { + this.backend = options.backend; + this.memory = new RasterMemoryBudgetController({ budgetBytes: DEFAULT_CACHE_BUDGET_BYTES }); + this.bitmaps = createDecodedBitmapPool({ onBytesChange: (bytes) => this.memory.setDecodedBytes(bytes) }); + this.layers = createLayerCacheStore(options.backend, { onVersionChange: options.onVersionChange }); + this.getDocument = options.getDocument ?? (() => null); + this.getLayerImageName = options.getLayerImageName ?? (() => null); + this.imageResolver = options.imageResolver ?? null; + this.derived = createDerivedSurfaceCache(options.diagnostics); + this.adjustments = createAdjustedSurfaceCache(options.backend, this.derived); + } + + async decodeImage( + image: CanvasImageRef, + options: { + signal?: AbortSignal; + isCurrent?: () => boolean; + scaleToImage?: boolean; + validateDecoded?: (width: number, height: number) => void; + } = {} + ): Promise { + if (!this.imageResolver) { + throw new Error('RasterController requires an image resolver to decode images.'); + } + if (options.signal?.aborted) { + return { status: 'aborted' }; + } + const blob = await this.imageResolver(image.imageName, options.signal); + if (options.signal?.aborted) { + return { status: 'aborted' }; + } + if (options.isCurrent && !options.isCurrent()) { + return { status: 'stale' }; + } + const bitmap = await this.backend.createImageBitmap(blob); + if (options.signal?.aborted || (options.isCurrent && !options.isCurrent())) { + bitmap.close(); + return { status: options.signal?.aborted ? 'aborted' : 'stale' }; + } + try { + options.validateDecoded?.(bitmap.width, bitmap.height); + const surface = this.backend.createSurface(image.width, image.height); + surface.ctx.setTransform(1, 0, 0, 1, 0, 0); + surface.ctx.clearRect(0, 0, image.width, image.height); + if (options.scaleToImage === false) { + surface.ctx.drawImage(bitmap, 0, 0); + } else { + surface.ctx.drawImage(bitmap, 0, 0, image.width, image.height); + } + return { decodedHeight: bitmap.height, decodedWidth: bitmap.width, status: 'ok', surface }; + } finally { + bitmap.close(); + } + } + + async decodeBlob( + blob: Blob, + dimensions?: { width: number; height: number; scale?: boolean } + ): Promise<{ surface: RasterSurface; decodedWidth: number; decodedHeight: number }> { + const bitmap = await this.backend.createImageBitmap(blob); + try { + const width = dimensions?.width ?? bitmap.width; + const height = dimensions?.height ?? bitmap.height; + const surface = this.backend.createSurface(width, height); + surface.ctx.setTransform(1, 0, 0, 1, 0, 0); + surface.ctx.clearRect(0, 0, width, height); + if (dimensions?.scale) { + surface.ctx.drawImage(bitmap, 0, 0, width, height); + } else { + surface.ctx.drawImage(bitmap, 0, 0); + } + return { decodedHeight: bitmap.height, decodedWidth: bitmap.width, surface }; + } finally { + bitmap.close(); + } + } + + getAdjustedSurface(layer: CanvasLayerContract, entry: LayerCacheEntry): RasterSurface | null { + return layer.type === 'raster' ? this.adjustments.get(layer.id, entry, layer.adjustments) : null; + } + + deleteDerivedSurfaces(layerId: string): void { + this.adjustments.delete(layerId); + this.derived.deleteLayer(layerId); + } + + getDocumentGeneration(): number { + return this.documentGeneration; + } + + invalidateDocument(): void { + this.documentGeneration += 1; + this.cancelAllRasterization(); + } + + getRasterizationJob(layerId: string): RasterizationJob | undefined { + return this.jobs.get(layerId); + } + + installRasterizationJob(layerId: string, job: RasterizationJob): void { + this.jobs.set(layerId, job); + this.activeJobs.add(job); + } + + finishRasterizationJob(layerId: string, job: RasterizationJob): void { + if (this.jobs.get(layerId) === job) { + this.jobs.delete(layerId); + } + this.activeJobs.delete(job); + } + + cancelRasterization(layerId: string): void { + const job = this.jobs.get(layerId); + if (!job) { + return; + } + this.jobs.delete(layerId); + job.controller.abort(); + } + + cancelAllRasterization(): void { + const jobs = [...this.jobs.values()]; + this.jobs.clear(); + for (const job of jobs) { + job.controller.abort(); + } + } + + hasActiveSourceImage(imageName: string): boolean { + for (const job of this.activeJobs) { + if (job.source.type === 'image' && job.source.image.imageName === imageName) { + return true; + } + } + return false; + } + + getTrackedImage(layerId: string): string | undefined { + return this.trackedImages.get(layerId); + } + setTrackedImage(layerId: string, imageName: string): void { + this.trackedImages.set(layerId, imageName); + } + deleteTrackedImage(layerId: string): void { + this.trackedImages.delete(layerId); + } + trackedImageIds(): string[] { + return [...this.trackedImages.keys()]; + } + hasTrackedImage(imageName: string): boolean { + return [...this.trackedImages.values()].includes(imageName); + } + clearTrackedImages(): void { + this.trackedImages.clear(); + } + + getMirroredImage(layerId: string): string | undefined { + return this.mirroredImages.get(layerId); + } + setMirroredImage(layerId: string, imageName: string): void { + this.mirroredImages.set(layerId, imageName); + } + deleteMirroredImage(layerId: string): void { + this.mirroredImages.delete(layerId); + } + mirroredImageNames(): string[] { + return [...this.mirroredImages.values()]; + } + clearMirroredImages(): void { + this.mirroredImages.clear(); + } + + getThumbnailKey(layerId: string): string | undefined { + return this.thumbnailKeys.get(layerId); + } + setThumbnailKey(layerId: string, key: string): void { + this.thumbnailKeys.set(layerId, key); + } + deleteThumbnailKey(layerId: string): void { + this.thumbnailKeys.delete(layerId); + } + clearThumbnailKeys(): void { + this.thumbnailKeys.clear(); + } + + releaseBitmapIfUnreferenced(imageName: string): void { + // Decoded bitmaps are lease-owned and close automatically after the final + // rasterizer releases them. Retained for callers that also update tracking. + void imageName; + } + + untrackLayerImage(layerId: string): void { + const imageName = this.getTrackedImage(layerId); + if (!imageName) { + return; + } + this.deleteTrackedImage(layerId); + this.releaseBitmapIfUnreferenced(imageName); + } + + trackPublishedLayerImage(layer: CanvasLayerContract): void { + const previous = this.getTrackedImage(layer.id); + const current = this.getLayerImageName(layer); + if (current) { + this.setTrackedImage(layer.id, current); + } else { + this.deleteTrackedImage(layer.id); + } + if (previous && previous !== current) { + this.releaseBitmapIfUnreferenced(previous); + } + } + + dropLayer(layerId: string): void { + this.cancelRasterization(layerId); + this.untrackLayerImage(layerId); + this.layers.delete(layerId); + this.deleteDerivedSurfaces(layerId); + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.cancelAllRasterization(); + this.clearTrackedImages(); + this.clearMirroredImages(); + this.clearThumbnailKeys(); + this.layers.dispose(); + this.bitmaps.dispose(); + this.adjustments.dispose(); + this.memory.dispose(); + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterExportController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterExportController.test.ts new file mode 100644 index 00000000000..4362cd4a17c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterExportController.test.ts @@ -0,0 +1,162 @@ +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it, vi } from 'vitest'; + +import { RasterExportController } from './rasterExportController'; + +describe('RasterExportController budget', () => { + it('returns over-budget before allocating a baked raster export', async () => { + const backend = createTestStubRasterBackend(); + const layers = createLayerCacheStore(backend); + const entry = layers.getOrCreate('layer', 100, 100); + entry.hasPublishedPixels = true; + entry.stale = false; + const layer = { + blendMode: 'normal' as const, + id: 'layer', + isEnabled: true, + isLocked: false, + name: 'Layer', + opacity: 1, + source: { image: { height: 100, imageName: 'layer.png', width: 100 }, type: 'image' as const }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster' as const, + }; + const document: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [layer], + selectedLayerId: null, + version: 2, + width: 100, + }; + const reserve = vi.fn(() => ({ availableBytes: 0, requestedBytes: 40_000, status: 'over-budget' as const })); + const controller = new RasterExportController({ + backend, + captureGuard: () => ({ cacheVersion: 1, documentGeneration: 1, layer, layerId: 'layer', projectId: 'p' }), + getDocument: () => document, + getOrStartRasterization: () => Promise.resolve('published'), + isGuardCurrent: () => true, + isRasterizing: () => false, + isSupportedSource: () => true, + layers, + reserve, + }); + + await expect(controller.baked('layer')).resolves.toEqual({ status: 'over-budget' }); + expect(reserve).toHaveBeenCalledWith(40_000); + }); + + it('holds the baked-surface reservation until blob encoding settles', async () => { + const stub = createTestStubRasterBackend(); + let resolveEncode!: (blob: Blob) => void; + const encodeSurface = vi.fn( + () => + new Promise((resolve) => { + resolveEncode = resolve; + }) + ); + const backend = { ...stub, encodeSurface }; + const layers = createLayerCacheStore(backend); + const entry = layers.getOrCreate('layer', 100, 100); + entry.hasPublishedPixels = true; + entry.stale = false; + const layer = { + adjustments: { brightness: 0.1, contrast: 0, saturation: 0 }, + blendMode: 'normal' as const, + id: 'layer', + isEnabled: true, + isLocked: false, + name: 'Layer', + opacity: 1, + source: { image: { height: 100, imageName: 'layer.png', width: 100 }, type: 'image' as const }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster' as const, + }; + const document: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [layer], + selectedLayerId: null, + version: 2, + width: 100, + }; + const release = vi.fn(); + const reserve = vi.fn(() => ({ lease: { release }, status: 'ok' as const })); + const controller = new RasterExportController({ + backend, + captureGuard: () => ({ cacheVersion: 1, documentGeneration: 1, layer, layerId: 'layer', projectId: 'p' }), + getDocument: () => document, + getOrStartRasterization: () => Promise.resolve('published'), + isGuardCurrent: () => true, + isRasterizing: () => false, + isSupportedSource: () => true, + layers, + reserve, + }); + + const pending = controller.blob('layer'); + await vi.waitFor(() => expect(encodeSurface).toHaveBeenCalledOnce()); + expect(release).not.toHaveBeenCalled(); + + resolveEncode(new Blob(['png'])); + await expect(pending).resolves.toMatchObject({ status: 'ok' }); + expect(release).toHaveBeenCalledOnce(); + }); + + it('transfers the baked-surface reservation to the caller until idempotent release', async () => { + const backend = createTestStubRasterBackend(); + const layers = createLayerCacheStore(backend); + const entry = layers.getOrCreate('layer', 100, 100); + entry.hasPublishedPixels = true; + entry.stale = false; + const layer = { + adjustments: { brightness: 0.1, contrast: 0, saturation: 0 }, + blendMode: 'normal' as const, + id: 'layer', + isEnabled: true, + isLocked: false, + name: 'Layer', + opacity: 1, + source: { image: { height: 100, imageName: 'layer.png', width: 100 }, type: 'image' as const }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster' as const, + }; + const document: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [layer], + selectedLayerId: null, + version: 2, + width: 100, + }; + const release = vi.fn(); + const reserve = vi.fn(() => ({ lease: { release }, status: 'ok' as const })); + const controller = new RasterExportController({ + backend, + captureGuard: () => ({ cacheVersion: 1, documentGeneration: 1, layer, layerId: 'layer', projectId: 'p' }), + getDocument: () => document, + getOrStartRasterization: () => Promise.resolve('published'), + isGuardCurrent: () => true, + isRasterizing: () => false, + isSupportedSource: () => true, + layers, + reserve, + }); + + const result = await controller.baked('layer'); + expect(result.status).toBe('ok'); + expect(reserve).toHaveBeenCalledWith(80_000); + expect(release).not.toHaveBeenCalled(); + if (result.status === 'ok') { + result.release(); + result.release(); + } + expect(release).toHaveBeenCalledOnce(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterExportController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterExportController.ts new file mode 100644 index 00000000000..bf84ccc9a80 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterExportController.ts @@ -0,0 +1,265 @@ +import type { + ExportBakedLayerBlobResult, + ExportBakedLayerPixelsOptions, + ExportLayerPixelsOptions, + LayerExportGuard, +} from '@workbench/canvas-engine/api'; +import type { LayerCacheEntry, LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterBackend } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerContract, CanvasLayerSourceContract } from '@workbench/types'; + +import { getSourceContentRect, renderableSourceOf } from '@workbench/canvas-engine/document/sources'; +import { fromTRS } from '@workbench/canvas-engine/math/mat2d'; +import { isEmpty, roundOut, transformBounds } from '@workbench/canvas-engine/math/rect'; +import { applyAdjustments } from '@workbench/canvas-engine/render/adjustments'; + +export type ExportLayerPixelsResult = + | { + status: 'ok'; + surface: ReturnType; + rect: Rect; + guard: LayerExportGuard; + release(): void; + } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' | 'aborted' }; + +export interface RasterExportControllerOptions { + readonly backend: RasterBackend; + readonly captureGuard: (layer: CanvasLayerContract, entry: LayerCacheEntry) => LayerExportGuard; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly getOrStartRasterization: ( + layer: CanvasLayerContract, + document: CanvasDocumentContractV2, + signal?: AbortSignal + ) => Promise<'published' | 'stale' | 'error' | 'aborted'>; + readonly isGuardCurrent: (guard: LayerExportGuard) => boolean; + readonly isRasterizing: (layer: CanvasLayerContract) => boolean; + readonly isSupportedSource: (source: CanvasLayerSourceContract) => boolean; + readonly layers: LayerCacheStore; + readonly reserve?: ( + bytes: number + ) => + | { status: 'ok'; lease: { release(): void } } + | { status: 'over-budget'; requestedBytes: number; availableBytes: number }; + readonly pin?: (layerId: string) => { release(): void }; +} + +interface ReservedExportLayerPixels { + readonly result: ExportLayerPixelsResult; + release(): void; +} + +const noReservedPixels = (result: ExportLayerPixelsResult): ReservedExportLayerPixels => ({ + release: () => undefined, + result, +}); + +/** Owns cache-backed, transformed, and encoded layer export primitives. */ +export class RasterExportController { + constructor(private readonly options: RasterExportControllerOptions) {} + + private applyAdjustments( + result: Extract, + shouldApply: boolean + ): ExportLayerPixelsResult { + const layer = result.guard.layer; + if (!shouldApply || layer.type !== 'raster' || !layer.adjustments) { + return result; + } + const reservation = this.options.reserve?.(result.rect.width * result.rect.height * 8); + if (reservation?.status === 'over-budget') { + result.release(); + return { status: 'over-budget' }; + } + const pinLease = this.options.pin?.(result.guard.layerId); + let released = false; + const release = (): void => { + if (released) { + return; + } + released = true; + result.release(); + pinLease?.release(); + if (reservation?.status === 'ok') { + reservation.lease.release(); + } + }; + try { + const surface = this.options.backend.createSurface(result.rect.width, result.rect.height); + const ctx = surface.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, result.rect.width, result.rect.height); + ctx.drawImage(result.surface.canvas, 0, 0); + const imageData = ctx.getImageData(0, 0, result.rect.width, result.rect.height); + applyAdjustments(imageData, layer.adjustments); + ctx.putImageData(imageData, 0, 0); + return { ...result, release, surface }; + } catch (error) { + release(); + throw error; + } + } + + async rasterize(layerId: string, options: ExportLayerPixelsOptions = {}): Promise { + const document = this.options.getDocument(); + if (!document) { + return { status: 'missing' }; + } + const layer = document.layers.find((candidate) => candidate.id === layerId); + const source = layer ? renderableSourceOf(layer) : null; + if (!layer || !source) { + return { status: 'missing' }; + } + if (!options.includeDisabled && !layer.isEnabled) { + return { status: 'disabled' }; + } + if (!this.options.isSupportedSource(source)) { + return { status: 'unsupported' }; + } + const liveEntry = this.options.layers.get(layerId); + if (liveEntry && !liveEntry.stale && !this.options.isRasterizing(layer) && !isEmpty(liveEntry.rect)) { + return this.applyAdjustments( + { + guard: this.options.captureGuard(layer, liveEntry), + release: () => undefined, + rect: liveEntry.rect, + status: 'ok', + surface: liveEntry.surface, + }, + options.applyAdjustments === true + ); + } + const contentRect = getSourceContentRect(layer, document); + if (isEmpty(contentRect)) { + return { status: 'empty' }; + } + const reservation = this.options.reserve?.(contentRect.width * contentRect.height * 8); + if (reservation?.status === 'over-budget') { + return { status: 'over-budget' }; + } + const pinLease = this.options.pin?.(layerId); + try { + const rasterized = await this.options.getOrStartRasterization(layer, document, options.signal); + if (rasterized !== 'published') { + return { status: rasterized === 'aborted' ? 'aborted' : 'not-ready' }; + } + } finally { + pinLease?.release(); + if (reservation?.status === 'ok') { + reservation.lease.release(); + } + } + const currentDocument = this.options.getDocument(); + const currentLayer = currentDocument?.layers.find((candidate) => candidate.id === layerId); + const entry = this.options.layers.get(layerId); + if (!currentLayer || !entry || entry.stale) { + return { status: 'not-ready' }; + } + const currentSource = renderableSourceOf(currentLayer); + if (!currentSource) { + return { status: 'missing' }; + } + if (!options.includeDisabled && !currentLayer.isEnabled) { + return { status: 'disabled' }; + } + if (!this.options.isSupportedSource(currentSource)) { + return { status: 'unsupported' }; + } + if (isEmpty(entry.rect)) { + return { status: 'empty' }; + } + return this.applyAdjustments( + { + guard: this.options.captureGuard(currentLayer, entry), + release: () => undefined, + rect: entry.rect, + status: 'ok', + surface: entry.surface, + }, + options.applyAdjustments === true + ); + } + + private async reserveBaked( + layerId: string, + options: ExportBakedLayerPixelsOptions = {} + ): Promise { + const raw = await this.rasterize(layerId, { ...options, applyAdjustments: false }); + if (raw.status !== 'ok') { + return noReservedPixels(raw); + } + const layer = raw.guard.layer; + const matrix = fromTRS( + { x: layer.transform.x, y: layer.transform.y }, + layer.transform.rotation, + layer.transform.scaleX, + layer.transform.scaleY + ); + const rect = roundOut(transformBounds(matrix, raw.rect)); + if (isEmpty(rect)) { + raw.release(); + return noReservedPixels({ status: 'empty' }); + } + const appliesAdjustments = options.applyAdjustments !== false && layer.type === 'raster' && !!layer.adjustments; + const reservation = this.options.reserve?.(rect.width * rect.height * (appliesAdjustments ? 8 : 4)); + if (reservation?.status === 'over-budget') { + raw.release(); + return noReservedPixels({ status: 'over-budget' }); + } + const pinLease = this.options.pin?.(layerId); + let released = false; + const release = (): void => { + if (released) { + return; + } + released = true; + pinLease?.release(); + if (reservation?.status === 'ok') { + reservation.lease.release(); + } + raw.release(); + }; + try { + const surface = this.options.backend.createSurface(rect.width, rect.height); + const ctx = surface.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, rect.width, rect.height); + ctx.setTransform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e - rect.x, matrix.f - rect.y); + ctx.drawImage(raw.surface.canvas, raw.rect.x, raw.rect.y); + if (appliesAdjustments && layer.type === 'raster' && layer.adjustments) { + const imageData = ctx.getImageData(0, 0, rect.width, rect.height); + applyAdjustments(imageData, layer.adjustments); + ctx.putImageData(imageData, 0, 0); + } + return { release, result: { guard: raw.guard, rect, release, status: 'ok', surface } }; + } catch (error) { + release(); + throw error; + } + } + + async baked(layerId: string, options: ExportBakedLayerPixelsOptions = {}): Promise { + const reserved = await this.reserveBaked(layerId, options); + return reserved.result; + } + + async blob(layerId: string, options: ExportBakedLayerPixelsOptions = {}): Promise { + const reserved = await this.reserveBaked(layerId, options); + try { + const result = reserved.result; + if (result.status !== 'ok') { + return result; + } + const blob = await this.options.backend.encodeSurface(result.surface, 'image/png'); + if (!this.options.isGuardCurrent(result.guard)) { + return { status: 'not-ready' }; + } + return { blob, guard: result.guard, rect: result.rect, status: 'ok' }; + } finally { + reserved.release(); + } + } + + dispose(): void {} +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterMemoryBudgetController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterMemoryBudgetController.test.ts new file mode 100644 index 00000000000..a38d9cade23 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterMemoryBudgetController.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { RasterMemoryBudgetController } from './rasterMemoryBudgetController'; + +describe('RasterMemoryBudgetController', () => { + it('reports the currently available bytes used to derive background pixel-area limits', () => { + const memory = new RasterMemoryBudgetController({ budgetBytes: 1_000 }); + memory.setBaseBytes(400); + memory.setDecodedBytes(100); + + expect(memory.getAvailableBytes()).toBe(500); + }); + + it('accounts for owned bytes and refuses background reservations beyond the soft limit', () => { + const memory = new RasterMemoryBudgetController({ budgetBytes: 1_000 }); + + memory.setBaseBytes(400); + memory.setDerivedBytes(200); + memory.setDecodedBytes(100); + memory.setDetachedBytes(50); + + const accepted = memory.reserve(200, { generation: 1, purpose: 'thumbnail' }); + const refused = memory.reserve(100, { generation: 1, purpose: 'raster-export' }); + + expect(accepted.status).toBe('ok'); + expect(refused).toEqual({ availableBytes: 50, requestedBytes: 100, status: 'over-budget' }); + expect(memory.snapshot()).toEqual({ + baseBytes: 400, + decodedBytes: 100, + derivedBytes: 200, + detachedBytes: 50, + reservedBytes: 200, + totalBytes: 950, + }); + }); + + it('makes generation reservation and pin release idempotent across cancellation and disposal', () => { + const memory = new RasterMemoryBudgetController({ budgetBytes: 1_000 }); + const reserved = memory.reserve(400, { generation: 7, purpose: 'psd-export' }); + expect(reserved.status).toBe('ok'); + if (reserved.status !== 'ok') { + throw new Error('Expected reservation'); + } + const pin = memory.pin('layer-a', 7); + + memory.releaseGeneration(7); + reserved.lease.release(); + pin.release(); + memory.releaseGeneration(7); + memory.dispose(); + memory.dispose(); + + expect(memory.snapshot().reservedBytes).toBe(0); + expect(memory.isPinned('layer-a')).toBe(false); + }); + + it('keeps caller-owned detached snapshots accounted across generation release', () => { + const memory = new RasterMemoryBudgetController({ budgetBytes: 1_000 }); + const detached = memory.trackDetached(300, 9); + + expect(memory.snapshot().detachedBytes).toBe(300); + memory.releaseGeneration(9); + expect(memory.snapshot().detachedBytes).toBe(300); + detached.release(); + expect(memory.snapshot().detachedBytes).toBe(0); + }); + + it('keeps in-flight operation reservations and pins across generation release', () => { + const memory = new RasterMemoryBudgetController({ budgetBytes: 1_000 }); + const reservation = memory.reserveOperation(300, { purpose: 'invocation-composite' }); + expect(reservation.status).toBe('ok'); + if (reservation.status !== 'ok') { + throw new Error('Expected operation reservation'); + } + const pin = memory.pinOperation('layer-a'); + + memory.releaseGeneration(4); + + expect(memory.snapshot().reservedBytes).toBe(300); + expect(memory.isPinned('layer-a')).toBe(true); + + reservation.lease.release(); + pin.release(); + expect(memory.snapshot().reservedBytes).toBe(0); + expect(memory.isPinned('layer-a')).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterMemoryBudgetController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterMemoryBudgetController.ts new file mode 100644 index 00000000000..58a090699e3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterMemoryBudgetController.ts @@ -0,0 +1,235 @@ +import { DEFAULT_CACHE_BUDGET_BYTES } from '@workbench/canvas-engine/render/layerCache'; + +export type RasterBackgroundPurpose = + | 'background-snapshot' + | 'invocation-composite' + | 'thumbnail' + | 'raster-export' + | 'psd-export'; + +export interface RasterMemoryLease { + release(): void; +} + +export type RasterMemoryReservationResult = + | { status: 'ok'; lease: RasterMemoryLease } + | { status: 'over-budget'; requestedBytes: number; availableBytes: number }; + +export interface RasterMemorySnapshot { + baseBytes: number; + derivedBytes: number; + decodedBytes: number; + detachedBytes: number; + reservedBytes: number; + totalBytes: number; +} + +interface GenerationLease extends RasterMemoryLease { + readonly generation: number; +} + +const normalizedBytes = (bytes: number): number => Math.max(0, Math.ceil(bytes)); + +/** Owns byte accounting and generation-scoped background allocation leases. */ +export class RasterMemoryBudgetController { + readonly budgetBytes: number; + private baseBytes = 0; + private derivedBytes = 0; + private decodedBytes = 0; + private reportedDetachedBytes = 0; + private leasedDetachedBytes = 0; + private reservedBytes = 0; + private readonly generationLeases = new Map>(); + private readonly pins = new Map>(); + private disposed = false; + + constructor(options: { budgetBytes?: number } = {}) { + this.budgetBytes = normalizedBytes(options.budgetBytes ?? DEFAULT_CACHE_BUDGET_BYTES); + } + + setBaseBytes(bytes: number): void { + this.baseBytes = normalizedBytes(bytes); + } + + setDerivedBytes(bytes: number): void { + this.derivedBytes = normalizedBytes(bytes); + } + + setDecodedBytes(bytes: number): void { + this.decodedBytes = normalizedBytes(bytes); + } + + setDetachedBytes(bytes: number): void { + this.reportedDetachedBytes = normalizedBytes(bytes); + } + + trackDetached(bytes: number, _generation: number): RasterMemoryLease { + if (this.disposed) { + return { release: () => undefined }; + } + const trackedBytes = normalizedBytes(bytes); + this.leasedDetachedBytes += trackedBytes; + let released = false; + return { + release: () => { + if (released) { + return; + } + released = true; + this.leasedDetachedBytes = Math.max(0, this.leasedDetachedBytes - trackedBytes); + }, + }; + } + + reserve( + requestedBytes: number, + options: { generation: number; purpose: RasterBackgroundPurpose } + ): RasterMemoryReservationResult { + const bytes = normalizedBytes(requestedBytes); + const availableBytes = this.getAvailableBytes(); + if (this.disposed || bytes > availableBytes) { + return { availableBytes, requestedBytes: bytes, status: 'over-budget' }; + } + this.reservedBytes += bytes; + const lease = this.createGenerationLease(options.generation, () => { + this.reservedBytes = Math.max(0, this.reservedBytes - bytes); + }); + return { lease, status: 'ok' }; + } + + /** Reserves bytes for an in-flight operation independently of lifecycle generations. */ + reserveOperation( + requestedBytes: number, + _options: { purpose: RasterBackgroundPurpose } + ): RasterMemoryReservationResult { + const bytes = normalizedBytes(requestedBytes); + const availableBytes = this.getAvailableBytes(); + if (this.disposed || bytes > availableBytes) { + return { availableBytes, requestedBytes: bytes, status: 'over-budget' }; + } + this.reservedBytes += bytes; + return { + lease: this.createOwnedLease(() => { + this.reservedBytes = Math.max(0, this.reservedBytes - bytes); + }), + status: 'ok', + }; + } + + getAvailableBytes(): number { + return Math.max(0, this.budgetBytes - this.snapshot().totalBytes); + } + + pin(layerId: string, generation: number): RasterMemoryLease { + if (this.disposed) { + return { release: () => undefined }; + } + const lease = this.createGenerationLease(generation, () => { + const layerPins = this.pins.get(layerId); + layerPins?.delete(lease); + if (layerPins?.size === 0) { + this.pins.delete(layerId); + } + }); + const layerPins = this.pins.get(layerId) ?? new Set(); + layerPins.add(lease); + this.pins.set(layerId, layerPins); + return lease; + } + + /** Pins a cache for an in-flight operation independently of lifecycle generations. */ + pinOperation(layerId: string): RasterMemoryLease { + if (this.disposed) { + return { release: () => undefined }; + } + const lease = this.createOwnedLease(() => { + const layerPins = this.pins.get(layerId); + layerPins?.delete(lease); + if (layerPins?.size === 0) { + this.pins.delete(layerId); + } + }); + const layerPins = this.pins.get(layerId) ?? new Set(); + layerPins.add(lease); + this.pins.set(layerId, layerPins); + return lease; + } + + isPinned(layerId: string): boolean { + return (this.pins.get(layerId)?.size ?? 0) > 0; + } + + pinnedLayerIds(): string[] { + return [...this.pins.keys()]; + } + + releaseGeneration(generation: number): void { + const leases = [...(this.generationLeases.get(generation) ?? [])]; + for (const lease of leases) { + lease.release(); + } + } + + snapshot(): RasterMemorySnapshot { + const detachedBytes = this.reportedDetachedBytes + this.leasedDetachedBytes; + return { + baseBytes: this.baseBytes, + decodedBytes: this.decodedBytes, + derivedBytes: this.derivedBytes, + detachedBytes, + reservedBytes: this.reservedBytes, + totalBytes: this.baseBytes + this.derivedBytes + this.decodedBytes + detachedBytes + this.reservedBytes, + }; + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + while (this.generationLeases.size > 0) { + const generation = this.generationLeases.keys().next().value; + if (generation === undefined) { + break; + } + this.releaseGeneration(generation); + } + this.pins.clear(); + } + + private createGenerationLease(generation: number, onRelease: () => void): GenerationLease { + let released = false; + const lease: GenerationLease = { + generation, + release: () => { + if (released) { + return; + } + released = true; + onRelease(); + const generationSet = this.generationLeases.get(generation); + generationSet?.delete(lease); + if (generationSet?.size === 0) { + this.generationLeases.delete(generation); + } + }, + }; + const generationSet = this.generationLeases.get(generation) ?? new Set(); + generationSet.add(lease); + this.generationLeases.set(generation, generationSet); + return lease; + } + + private createOwnedLease(onRelease: () => void): RasterMemoryLease { + let released = false; + return { + release: () => { + if (released) { + return; + } + released = true; + onRelease(); + }, + }; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterizeLayerController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterizeLayerController.ts new file mode 100644 index 00000000000..2c547f71455 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterizeLayerController.ts @@ -0,0 +1,105 @@ +import type { History } from '@workbench/canvas-engine/history/history'; +import type { LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterBackend } from '@workbench/canvas-engine/render/raster'; +import type { RasterizeDeps } from '@workbench/canvas-engine/render/rasterizers'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { getSourceContentRect } from '@workbench/canvas-engine/document/sources'; +import { roundOut, transformBounds } from '@workbench/canvas-engine/math/rect'; +import { rasterizeSource } from '@workbench/canvas-engine/render/rasterizers'; +import { bakeMatrix } from '@workbench/canvas-engine/transform/transformMath'; + +export interface RasterizeLayerControllerOptions { + readonly backend: RasterBackend; + readonly layers: LayerCacheStore; + readonly history: History; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly rasterizeDeps: (document: CanvasDocumentContractV2) => RasterizeDeps; + readonly dispatch: (action: CanvasProjectMutation) => void; + readonly canEdit: () => boolean; + readonly isGestureActive: () => boolean; + readonly endBurst: () => void; + readonly notifyPainted: (layerId: string) => void; + readonly markDirty: (layerId: string) => void; +} + +/** Owns conversion of parametric raster layers into persisted paint pixels. */ +export class RasterizeLayerController { + private disposed = false; + + constructor(private readonly deps: RasterizeLayerControllerOptions) {} + + rasterize(layerId: string): boolean { + if (this.disposed || !this.deps.canEdit() || this.deps.isGestureActive()) { + return false; + } + this.deps.endBurst(); + const document = this.deps.getDocument(); + const layer = document?.layers.find((candidate) => candidate.id === layerId); + if (!document || !layer || layer.type !== 'raster' || layer.isLocked) { + return false; + } + const source = layer.source; + if ( + (source.type !== 'shape' && source.type !== 'gradient' && source.type !== 'text') || + (source.type === 'shape' && source.kind === 'polygon') + ) { + return false; + } + const parametricLayer: CanvasLayerContract = structuredClone(layer); + const apply = (): void => { + const liveDocument = this.deps.getDocument(); + const liveLayer = liveDocument?.layers.find((candidate) => candidate.id === layerId); + if (!liveDocument || !liveLayer || liveLayer.type !== 'raster') { + return; + } + const liveSource = liveLayer.source; + if (liveSource.type !== 'shape' && liveSource.type !== 'gradient' && liveSource.type !== 'text') { + return; + } + const contentRect = getSourceContentRect(liveLayer, liveDocument); + const entry = this.deps.layers.getOrCreateRect(layerId, contentRect); + entry.rect = contentRect; + entry.stale = true; + void rasterizeSource(liveSource, this.deps.rasterizeDeps(liveDocument), entry.surface).then((result) => { + entry.rect = result.rect; + }); + entry.stale = false; + const matrix = bakeMatrix(liveLayer.transform); + const bakedRect = roundOut(transformBounds(matrix, contentRect)); + const baked = this.deps.backend.createSurface(bakedRect.width, bakedRect.height); + baked.ctx.setTransform(1, 0, 0, 1, 0, 0); + baked.ctx.clearRect(0, 0, bakedRect.width, bakedRect.height); + baked.ctx.imageSmoothingEnabled = true; + baked.ctx.setTransform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e - bakedRect.x, matrix.f - bakedRect.y); + baked.ctx.drawImage(entry.surface.canvas, contentRect.x, contentRect.y); + baked.ctx.setTransform(1, 0, 0, 1, 0, 0); + const paintLayer: CanvasLayerContract = { + ...liveLayer, + source: { bitmap: null, offset: { x: bakedRect.x, y: bakedRect.y }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + }; + this.deps.dispatch({ id: layerId, layer: paintLayer, targetType: 'raster', type: 'convertCanvasLayer' }); + this.deps.layers.delete(layerId); + const target = this.deps.layers.getOrCreateRect(layerId, bakedRect); + target.surface.ctx.drawImage(baked.canvas, 0, 0); + target.stale = false; + this.deps.notifyPainted(layerId); + this.deps.markDirty(layerId); + }; + apply(); + this.deps.history.push({ + bytes: 256, + label: 'Rasterize layer', + redo: apply, + undo: () => + this.deps.dispatch({ id: layerId, layer: parametricLayer, targetType: 'raster', type: 'convertCanvasLayer' }), + }); + return true; + } + + dispose(): void { + this.disposed = true; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/renderController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/renderController.test.ts new file mode 100644 index 00000000000..031a5a164ae --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/renderController.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { RenderController } from './renderController'; + +describe('RenderController', () => { + it('owns scheduling lifecycle and disposes idempotently', () => { + let frame: FrameRequestCallback | null = null; + const render = vi.fn(); + const controller = new RenderController({ + applyCursor: vi.fn(), + cancelFrame: () => { + frame = null; + }, + clearPreview: vi.fn(), + getInputHandlers: () => + ({ + onKeyDown: vi.fn(), + onKeyUp: vi.fn(), + onPointerCancel: vi.fn(), + onPointerDown: vi.fn(), + onPointerEnter: vi.fn(), + onPointerLeave: vi.fn(), + onPointerMove: vi.fn(), + onPointerUp: vi.fn(), + onWheel: vi.fn(), + reset: vi.fn(), + }) as never, + isEngineDisposed: () => false, + onPageHide: vi.fn(), + onVisibilityChange: vi.fn(), + onWindowBlur: vi.fn(), + render, + requestFrame: (callback) => { + frame = callback; + return 1; + }, + setViewportReady: vi.fn(), + updateAnimation: vi.fn(), + updateCursor: vi.fn(), + }); + + controller.scheduler.invalidate({ overlay: true }); + expect(frame).not.toBeNull(); + const scheduled = frame as unknown as FrameRequestCallback; + frame = null; + scheduled(0); + expect(render).toHaveBeenCalledOnce(); + controller.dispose(); + controller.dispose(); + controller.scheduler.invalidate({ all: true }); + expect(frame).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/renderController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/renderController.ts new file mode 100644 index 00000000000..f1c60737837 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/renderController.ts @@ -0,0 +1,157 @@ +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { RenderScheduler, RenderSchedulerDeps } from '@workbench/canvas-engine/render/scheduler'; + +import { createRenderScheduler } from '@workbench/canvas-engine/render/scheduler'; + +import { PreviewStateController } from './previewStateController'; + +const wrapCanvasSurface = (canvas: HTMLCanvasElement): RasterSurface => { + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to acquire a 2D context from the canvas element'); + } + return { + canvas, + ctx, + get height() { + return canvas.height; + }, + resize(width: number, height: number) { + canvas.width = width; + canvas.height = height; + }, + get width() { + return canvas.width; + }, + }; +}; + +export interface RenderControllerOptions extends RenderSchedulerDeps { + readonly isEngineDisposed: () => boolean; + readonly getInputHandlers: () => { + onPointerDown: (event: PointerEvent) => void; + onPointerMove: (event: PointerEvent) => void; + onPointerUp: (event: PointerEvent) => void; + onPointerCancel: (event: PointerEvent) => void; + onPointerEnter: (event: PointerEvent) => void; + onPointerLeave: (event: PointerEvent) => void; + onKeyDown: (event: KeyboardEvent) => void; + onKeyUp: (event: KeyboardEvent) => void; + onWheel: (event: WheelEvent) => void; + reset(): void; + }; + readonly onPageHide: () => void; + readonly onWindowBlur: () => void; + readonly onVisibilityChange: () => void; + readonly clearPreview: () => void; + readonly applyCursor: (value: string) => void; + readonly updateCursor: () => void; + readonly updateAnimation: () => void; + readonly setViewportReady: (ready: boolean) => void; +} + +/** Owns render targets, scheduling, DOM input bindings, and attach/detach lifecycle. */ +export class RenderController { + readonly scheduler: RenderScheduler; + readonly previews = new PreviewStateController(); + private screen: RasterSurface | null = null; + private overlay: RasterSurface | null = null; + private input: HTMLCanvasElement | null = null; + private disposed = false; + + constructor(private readonly options: RenderControllerOptions) { + this.scheduler = createRenderScheduler(options); + } + + getScreen(): RasterSurface | null { + return this.screen; + } + + getOverlay(): RasterSurface | null { + return this.overlay; + } + + getInputElement(): HTMLCanvasElement | null { + return this.input; + } + + attach(screenCanvas: HTMLCanvasElement, overlayCanvas: HTMLCanvasElement): void { + if (this.disposed || this.options.isEngineDisposed()) { + return; + } + if (this.input) { + this.detach(); + } + this.screen = wrapCanvasSurface(screenCanvas); + this.overlay = wrapCanvasSurface(overlayCanvas); + this.input = overlayCanvas; + const handlers = this.options.getInputHandlers(); + this.input.addEventListener('pointerdown', handlers.onPointerDown); + this.input.addEventListener('pointermove', handlers.onPointerMove); + this.input.addEventListener('pointerup', handlers.onPointerUp); + this.input.addEventListener('pointercancel', handlers.onPointerCancel); + this.input.addEventListener('pointerenter', handlers.onPointerEnter); + this.input.addEventListener('pointerleave', handlers.onPointerLeave); + this.input.addEventListener('wheel', handlers.onWheel, { passive: false }); + if (typeof globalThis.addEventListener === 'function') { + globalThis.addEventListener('keydown', handlers.onKeyDown); + globalThis.addEventListener('keyup', handlers.onKeyUp); + globalThis.addEventListener('pagehide', this.options.onPageHide); + globalThis.addEventListener('blur', this.options.onWindowBlur); + } + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', this.options.onVisibilityChange); + } + this.scheduler.resume(); + this.options.setViewportReady(true); + this.options.updateCursor(); + this.scheduler.invalidate({ all: true }); + this.options.updateAnimation(); + } + + detach(): void { + const handlers = this.options.getInputHandlers(); + if (this.input) { + this.input.removeEventListener('pointerdown', handlers.onPointerDown); + this.input.removeEventListener('pointermove', handlers.onPointerMove); + this.input.removeEventListener('pointerup', handlers.onPointerUp); + this.input.removeEventListener('pointercancel', handlers.onPointerCancel); + this.input.removeEventListener('pointerenter', handlers.onPointerEnter); + this.input.removeEventListener('pointerleave', handlers.onPointerLeave); + this.input.removeEventListener('wheel', handlers.onWheel); + this.options.applyCursor(''); + } + if (typeof globalThis.removeEventListener === 'function') { + globalThis.removeEventListener('keydown', handlers.onKeyDown); + globalThis.removeEventListener('keyup', handlers.onKeyUp); + globalThis.removeEventListener('pagehide', this.options.onPageHide); + globalThis.removeEventListener('blur', this.options.onWindowBlur); + } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', this.options.onVisibilityChange); + } + handlers.reset(); + this.options.clearPreview(); + this.scheduler.pause(); + this.options.setViewportReady(false); + this.screen = null; + this.overlay = null; + this.input = null; + this.options.updateAnimation(); + } + + resize(width: number, height: number): void { + this.screen?.resize(width, height); + this.overlay?.resize(width, height); + } + + dispose(): void { + if (this.disposed) { + return; + } + this.detach(); + this.disposed = true; + this.scheduler.dispose(); + this.previews.dispose(); + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/selectionImageController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/selectionImageController.ts new file mode 100644 index 00000000000..5c4fe487960 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/selectionImageController.ts @@ -0,0 +1,86 @@ +import type { LayerExportGuard, ReplaceSelectionFromImageResult } from '@workbench/canvas-engine/api'; +import type { DecodeImageResult } from '@workbench/canvas-engine/controllers/rasterController'; +import type { SelectionState } from '@workbench/canvas-engine/selection/selectionState'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasImageRef } from '@workbench/types'; + +export interface SelectionImageControllerOptions { + readonly capturePermit: (owner?: Owner) => Permit | null; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly decodeImage: ( + image: CanvasImageRef, + options: { + signal?: AbortSignal; + isCurrent?: () => boolean; + scaleToImage?: boolean; + validateDecoded?: (width: number, height: number) => void; + } + ) => Promise; + readonly isGestureActive: () => boolean; + readonly isGuardCurrent: (guard: LayerExportGuard) => boolean; + readonly isPermitCurrent: (permit: Permit) => boolean; + readonly selection: SelectionState; +} + +/** Decodes guarded application results into the transient selection mask. */ +export class SelectionImageController { + constructor(private readonly options: SelectionImageControllerOptions) {} + + async replace( + guard: LayerExportGuard, + image: CanvasImageRef, + rect: Rect, + signal?: AbortSignal, + owner?: Owner + ): Promise { + const permit = this.options.capturePermit(owner); + if (!permit) { + return { status: 'busy' }; + } + if (signal?.aborted) { + return { status: 'aborted' }; + } + try { + const decoded = await this.options.decodeImage(image, { + isCurrent: () => this.options.isPermitCurrent(permit), + signal, + }); + if (decoded.status !== 'ok') { + return { status: decoded.status === 'aborted' ? 'aborted' : 'busy' }; + } + const pixels = decoded.surface; + const document = this.options.getDocument(); + if (!document) { + return { status: 'missing' }; + } + const layer = document.layers.find((candidate) => candidate.id === guard.layerId); + if (!layer) { + return { status: 'missing' }; + } + if (layer.isLocked) { + return { status: 'locked' }; + } + if (layer.type !== 'raster' && layer.type !== 'control') { + return { status: 'unsupported' }; + } + if (!this.options.isPermitCurrent(permit) || this.options.isGestureActive()) { + return { status: 'busy' }; + } + if (!this.options.isGuardCurrent(guard)) { + return { status: 'stale' }; + } + if (signal?.aborted) { + return { status: 'aborted' }; + } + this.options.selection.replaceMask({ rect: { ...rect }, surface: pixels }); + return { status: 'selected' }; + } catch (error) { + if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) { + return { status: 'aborted' }; + } + return { message: error instanceof Error ? error.message : String(error), status: 'failed' }; + } + } + + dispose(): void {} +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/selectionPixelController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/selectionPixelController.test.ts new file mode 100644 index 00000000000..2c183133dd4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/selectionPixelController.test.ts @@ -0,0 +1,62 @@ +import type { SelectionState } from '@workbench/canvas-engine/selection/selectionState'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { createHistory } from '@workbench/canvas-engine/history/history'; +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it, vi } from 'vitest'; + +import { SelectionPixelController } from './selectionPixelController'; + +describe('SelectionPixelController', () => { + it('owns raster fill persistence and history through declared ports', () => { + const backend = createTestStubRasterBackend(); + const layers = createLayerCacheStore(backend); + layers.getOrCreateRect('paint', { height: 2, width: 2, x: 0, y: 0 }); + const mask = backend.createSurface(2, 2); + mask.ctx.fillStyle = '#fff'; + mask.ctx.fillRect(0, 0, 2, 2); + const selection = { + bounds: () => ({ height: 2, width: 2, x: 0, y: 0 }), + mask: () => ({ rect: { height: 2, width: 2, x: 0, y: 0 }, surface: mask }), + } as unknown as SelectionState; + const document = { + layers: [ + { + id: 'paint', + isEnabled: true, + isLocked: false, + source: { type: 'paint' }, + type: 'raster', + }, + ], + selectedLayerId: 'paint', + } as CanvasDocumentContractV2; + const history = createHistory(); + const markDirty = vi.fn(); + const notifyPainted = vi.fn(); + const controller = new SelectionPixelController({ + applyImagePatch: vi.fn(), + backend, + beginControlEdit: () => null, + canEdit: () => true, + deleteDerived: vi.fn(), + endBurst: vi.fn(), + getDocument: () => document, + getFillColor: () => '#f00', + history, + invalidateLayer: vi.fn(), + isGestureActive: () => false, + layers, + markDirty, + notifyPainted, + selection, + }); + + controller.run('fill'); + + expect(markDirty).toHaveBeenCalledWith('paint'); + expect(notifyPainted).toHaveBeenCalledWith('paint'); + expect(history.canUndo()).toBe(true); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/selectionPixelController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/selectionPixelController.ts new file mode 100644 index 00000000000..75b427caa57 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/selectionPixelController.ts @@ -0,0 +1,266 @@ +import type { History } from '@workbench/canvas-engine/history/history'; +import type { ImagePatchApply } from '@workbench/canvas-engine/history/imagePatch'; +import type { LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { SelectionState } from '@workbench/canvas-engine/selection/selectionState'; +import type { ControlPixelEditTransaction } from '@workbench/canvas-engine/tools/tool'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { getSourceBounds } from '@workbench/canvas-engine/document/sources'; +import { createImagePatchEntry } from '@workbench/canvas-engine/history/imagePatch'; +import { intersect, isEmpty, roundOut } from '@workbench/canvas-engine/math/rect'; +import { eraseMaskedRegion, fillMaskedRegion } from '@workbench/canvas-engine/selection/selectionOps'; + +type PixelTarget = + | { kind: 'raster'; layerId: string; transparencyLocked: boolean } + | { kind: 'control'; transaction: ControlPixelEditTransaction; transparencyLocked: false }; + +export interface SelectionPixelControllerOptions { + readonly selection: SelectionState; + readonly backend: RasterBackend; + readonly layers: LayerCacheStore; + readonly history: History; + readonly applyImagePatch: ImagePatchApply; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly beginControlEdit: (layerId: string) => ControlPixelEditTransaction | null; + readonly canEdit: () => boolean; + readonly isGestureActive: () => boolean; + readonly getFillColor: () => string; + readonly endBurst: () => void; + readonly deleteDerived: (layerId: string) => void; + readonly invalidateLayer: (layerId: string) => void; + readonly notifyPainted: (layerId: string) => void; + readonly markDirty: (layerId: string) => void; +} + +const imageDataEqual = (left: ImageData, right: ImageData): boolean => { + if (left.width !== right.width || left.height !== right.height || left.data.length !== right.data.length) { + return false; + } + for (let index = 0; index < left.data.length; index += 1) { + if (left.data[index] !== right.data[index]) { + return false; + } + } + return true; +}; + +/** Owns selection-driven fill/erase pixel transactions. */ +export class SelectionPixelController { + private disposed = false; + + constructor(private readonly deps: SelectionPixelControllerOptions) {} + + private target(): PixelTarget | null { + const document = this.deps.getDocument(); + if (!document?.selectedLayerId) { + return null; + } + const layer = document.layers.find((candidate) => candidate.id === document.selectedLayerId); + if (layer?.type === 'raster' && layer.source.type === 'paint' && !layer.isLocked && layer.isEnabled) { + return { kind: 'raster', layerId: layer.id, transparencyLocked: layer.isTransparencyLocked === true }; + } + if (layer?.type === 'control') { + const transaction = this.deps.beginControlEdit(layer.id); + return transaction ? { kind: 'control', transaction, transparencyLocked: false } : null; + } + return null; + } + + run(kind: 'fill' | 'erase'): void { + if (this.disposed || !this.deps.canEdit() || this.deps.isGestureActive()) { + return; + } + const document = this.deps.getDocument(); + const placedMask = this.deps.selection.mask(); + const bounds = this.deps.selection.bounds(); + if (!document || !placedMask || !bounds) { + return; + } + const selectionRect = roundOut(bounds); + const selectedLayer = document.layers.find((candidate) => candidate.id === document.selectedLayerId); + if ( + kind === 'erase' && + selectedLayer?.type === 'control' && + !intersect(selectionRect, roundOut(getSourceBounds(selectedLayer, document))) + ) { + return; + } + const target = this.target(); + if (!target) { + return; + } + const cancelControl = (): void => { + if (target.kind === 'control') { + target.transaction.cancel(); + } + }; + if (kind === 'erase' && target.transparencyLocked) { + cancelControl(); + return; + } + const layerId = target.kind === 'control' ? target.transaction.layerId : target.layerId; + let before: ImageData | null = null; + let editOrigin: { x: number; y: number } | null = null; + let editRect: Rect | null = null; + let editSurface: RasterSurface | null = null; + let commitStarted = false; + let rollbackStarted = false; + let growthSnapshot: + | { + hasPublishedPixels: boolean; + lastUsed: number; + pixels: ImageData | null; + rect: Rect; + stale: boolean; + surface: RasterSurface; + version: number; + } + | null + | undefined; + const rollback = (): void => { + if (target.kind !== 'control' || rollbackStarted) { + return; + } + rollbackStarted = true; + try { + if (growthSnapshot !== undefined) { + if (growthSnapshot === null) { + this.deps.layers.delete(layerId); + } else { + const current = this.deps.layers.get(layerId); + if (current) { + growthSnapshot.surface.resize(growthSnapshot.rect.width, growthSnapshot.rect.height); + if (growthSnapshot.pixels) { + growthSnapshot.surface.ctx.putImageData(growthSnapshot.pixels, 0, 0); + } + current.hasPublishedPixels = growthSnapshot.hasPublishedPixels; + current.lastUsed = growthSnapshot.lastUsed; + current.rect = { ...growthSnapshot.rect }; + current.stale = growthSnapshot.stale; + current.surface = growthSnapshot.surface; + current.version = growthSnapshot.version; + } + } + this.deps.deleteDerived(layerId); + this.deps.invalidateLayer(layerId); + } else if (before && editOrigin && editRect && editSurface) { + editSurface.ctx.putImageData(before, editRect.x - editOrigin.x, editRect.y - editOrigin.y); + this.deps.deleteDerived(layerId); + this.deps.invalidateLayer(layerId); + } + } finally { + target.transaction.cancel(); + } + }; + try { + if (target.kind === 'control' && kind === 'fill') { + const existing = this.deps.layers.get(layerId); + const needsGrowth = + !existing || + isEmpty(existing.rect) || + selectionRect.x < existing.rect.x || + selectionRect.y < existing.rect.y || + selectionRect.x + selectionRect.width > existing.rect.x + existing.rect.width || + selectionRect.y + selectionRect.height > existing.rect.y + existing.rect.height; + if (needsGrowth) { + growthSnapshot = existing + ? { + hasPublishedPixels: existing.hasPublishedPixels, + lastUsed: existing.lastUsed, + pixels: isEmpty(existing.rect) + ? null + : existing.surface.ctx.getImageData(0, 0, existing.rect.width, existing.rect.height), + rect: { ...existing.rect }, + stale: existing.stale, + surface: existing.surface, + version: existing.version, + } + : null; + } + } + let rect: Rect | null; + let entry; + if (kind === 'fill' && !target.transparencyLocked) { + rect = selectionRect; + entry = this.deps.layers.growToRect(layerId, selectionRect); + } else { + const existing = this.deps.layers.get(layerId); + if (!existing || isEmpty(existing.rect)) { + cancelControl(); + return; + } + rect = intersect(selectionRect, existing.rect); + entry = existing; + } + if (!rect || isEmpty(rect)) { + cancelControl(); + return; + } + this.deps.endBurst(); + const surface = entry.surface; + const origin = { x: entry.rect.x, y: entry.rect.y }; + before = surface.ctx.getImageData(rect.x - origin.x, rect.y - origin.y, rect.width, rect.height); + editOrigin = origin; + editRect = rect; + editSurface = surface; + if (kind === 'fill') { + fillMaskedRegion({ + backend: this.deps.backend, + color: this.deps.getFillColor(), + composite: target.transparencyLocked ? 'source-atop' : 'source-over', + mask: placedMask.surface, + maskOrigin: placedMask.rect, + rect, + target: surface, + targetOrigin: origin, + }); + } else { + eraseMaskedRegion({ + backend: this.deps.backend, + mask: placedMask.surface, + maskOrigin: placedMask.rect, + rect, + target: surface, + targetOrigin: origin, + }); + } + const after = surface.ctx.getImageData(rect.x - origin.x, rect.y - origin.y, rect.width, rect.height); + const label = kind === 'fill' ? 'Fill selection' : 'Erase selection'; + if (target.kind === 'control') { + if (imageDataEqual(before, after)) { + rollback(); + return; + } + commitStarted = true; + target.transaction.commitPatch(label, { after, before, rect }); + } else { + this.deps.notifyPainted(target.layerId); + this.deps.markDirty(target.layerId); + if (!this.deps.history.isApplying()) { + this.deps.history.push( + createImagePatchEntry({ + after, + apply: this.deps.applyImagePatch, + before, + label, + layerId: target.layerId, + rect, + }) + ); + } + } + } catch (error) { + if (target.kind !== 'control' || commitStarted) { + throw error; + } + rollback(); + throw error; + } + } + + dispose(): void { + this.disposed = true; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/stagedResultController.integration.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/stagedResultController.integration.test.ts new file mode 100644 index 00000000000..a247b1c0be3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/stagedResultController.integration.test.ts @@ -0,0 +1,151 @@ +import type { CanvasProjectMutationPort } from '@workbench/canvasProjectMutationPort'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasStagingCandidateContract } from '@workbench/types'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { createCanvasEngine } from '@workbench/canvas-operations/createCanvasEngine'; +import { createCanvasProjectMutationPort } from '@workbench/canvasProjectMutationPort'; +import { createWorkbenchStore } from '@workbench/workbenchStore'; +import { describe, expect, it } from 'vitest'; + +const candidate: CanvasStagingCandidateContract = { + height: 40, + imageName: 'rollback-result.png', + imageUrl: '/rollback-result.png', + placement: { height: 80, opacity: 0.5, width: 60, x: 12, y: 18 }, + queuedAt: '2026-07-16T00:00:00.000Z', + sourceQueueItemId: 'queue-rollback', + thumbnailUrl: '/rollback-result-thumb.png', + width: 30, +}; +const selection = { candidate, selectedImageIndex: 0 } as const; + +const createMirrorRejectingPort = ( + store: ReturnType, + projectId: string +): { arm: (type: CanvasProjectMutation['type']) => void; port: CanvasProjectMutationPort } => { + const real = createCanvasProjectMutationPort(store, projectId); + let faultActive = false; + let readsAfterCommit = 0; + let armedType: CanvasProjectMutation['type'] | null = null; + const port: CanvasProjectMutationPort = { + dispatch: (mutation) => { + if (mutation.type === armedType) { + faultActive = true; + readsAfterCommit = 0; + armedType = null; + } else { + faultActive = false; + } + return real.dispatch(mutation); + }, + getCanvasState: () => { + if (faultActive) { + readsAfterCommit += 1; + if (readsAfterCommit === 1 || readsAfterCommit === 3) { + throw new Error('document mirror read failed'); + } + } + return real.getCanvasState(); + }, + subscribe: real.subscribe, + }; + return { arm: (type) => (armedType = type), port }; +}; + +describe('staged result project-port integration', () => { + it('commits the actually selected slot when duplicate candidate keys have different placements', () => { + const store = createWorkbenchStore(); + const projectId = store.getState().activeProjectId; + const first = { ...candidate, placement: { ...candidate.placement, x: 10 } }; + const selected = { ...candidate, placement: { ...candidate.placement, x: 90 } }; + store.dispatch({ candidate: first, projectId, type: 'appendCanvasStagingCandidate' }); + store.dispatch({ candidate: selected, projectId, type: 'appendCanvasStagingCandidate' }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + mutationPort: createCanvasProjectMutationPort(store, projectId), + projectId, + reportError: () => undefined, + }); + + const result = engine.layers.commitStagedImage({ candidate: selected, selectedImageIndex: 1 }); + + expect(result.status).toBe('committed'); + expect(store.getState().projects[0]?.canvas.document.layers[0]?.transform.x).toBe(90); + engine.lifecycle.dispose(); + }); + + it('rolls back the exact layer, event, selection, and staging when initial mirror acceptance fails', () => { + const store = createWorkbenchStore(); + const projectId = store.getState().activeProjectId; + store.dispatch({ candidate, projectId, type: 'appendCanvasStagingCandidate' }); + const before = structuredClone(store.getState().projects.find((project) => project.id === projectId)!); + const rejectingPort = createMirrorRejectingPort(store, projectId); + rejectingPort.arm('commitStagedImage'); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + mutationPort: rejectingPort.port, + projectId, + reportError: () => undefined, + }); + + expect(engine.layers.commitStagedImage(selection)).toEqual({ + status: 'stale', + }); + expect(store.getState().projects.find((project) => project.id === projectId)).toEqual(before); + expect(engine.document.getDocument()).toEqual(before.canvas.document); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('compensates a reducer-accepted undo when mirror acceptance fails and keeps history retryable', () => { + const store = createWorkbenchStore(); + const projectId = store.getState().activeProjectId; + store.dispatch({ candidate, projectId, type: 'appendCanvasStagingCandidate' }); + const rejectingPort = createMirrorRejectingPort(store, projectId); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + mutationPort: rejectingPort.port, + projectId, + reportError: () => undefined, + }); + expect(engine.layers.commitStagedImage(selection).status).toBe('committed'); + const accepted = structuredClone(store.getState().projects.find((project) => project.id === projectId)!); + rejectingPort.arm('applyCanvasLayerStackMutation'); + + expect(() => engine.history.undo()).toThrow('document mirror read failed'); + expect(store.getState().projects.find((project) => project.id === projectId)).toEqual(accepted); + expect(engine.document.getDocument()).toEqual(accepted.canvas.document); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('compensates a reducer-accepted redo when mirror acceptance fails and keeps history retryable', () => { + const store = createWorkbenchStore(); + const projectId = store.getState().activeProjectId; + store.dispatch({ candidate, projectId, type: 'appendCanvasStagingCandidate' }); + const rejectingPort = createMirrorRejectingPort(store, projectId); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + mutationPort: rejectingPort.port, + projectId, + reportError: () => undefined, + }); + expect(engine.layers.commitStagedImage(selection).status).toBe('committed'); + engine.history.undo(); + const undone = structuredClone(store.getState().projects.find((project) => project.id === projectId)!); + rejectingPort.arm('applyCanvasLayerStackMutation'); + + expect(() => engine.history.redo()).toThrow('document mirror read failed'); + expect(store.getState().projects.find((project) => project.id === projectId)).toEqual(undone); + expect(engine.document.getDocument()).toEqual(undone.canvas.document); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + engine.lifecycle.dispose(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/stagedResultController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/stagedResultController.test.ts new file mode 100644 index 00000000000..d664eaad3d1 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/stagedResultController.test.ts @@ -0,0 +1,296 @@ +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasStagingCandidateContract, CanvasStateContractV2 } from '@workbench/types'; + +import { createHistory } from '@workbench/canvas-engine/history/history'; +import { describe, expect, it, vi } from 'vitest'; + +import { StagedResultController } from './stagedResultController'; + +const candidate: CanvasStagingCandidateContract = { + height: 40, + imageName: 'result.png', + imageUrl: '/result.png', + placement: { height: 80, opacity: 0.5, width: 60, x: 12, y: 18 }, + queuedAt: '2026-07-16T00:00:00.000Z', + sourceQueueItemId: 'queue-1', + thumbnailUrl: '/thumb.png', + width: 30, +}; +const selection = { candidate, selectedImageIndex: 0 } as const; + +const makeCanvas = (): CanvasStateContractV2 => ({ + document: { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [], + selectedLayerId: null, + version: 2, + width: 100, + }, + documentRevision: 0, + snapshots: [], + stagingArea: { + areThumbnailsVisible: false, + autoSwitchMode: 'off', + isVisible: true, + pendingImageIds: [candidate.imageName], + pendingImages: [candidate], + selectedImageIndex: 0, + }, + version: 2, +}); + +describe('StagedResultController', () => { + it('commits the guarded candidate and records history only after reducer and mirror acceptance', () => { + let reducerCanvas = makeCanvas(); + let mirrorDocument = reducerCanvas.document; + const history = createHistory(); + const dispatchPrepared = vi.fn( + (mutation: CanvasProjectMutation, reducerAccepted: () => boolean, mirrorAccepted: () => boolean) => { + if (mutation.type === 'applyCanvasLayerStackMutation') { + reducerCanvas = { + ...reducerCanvas, + document: { + ...reducerCanvas.document, + layers: mutation.removeIds + ? reducerCanvas.document.layers.filter((layer) => !mutation.removeIds?.includes(layer.id)) + : mutation.add + ? [...mutation.add.layers, ...reducerCanvas.document.layers] + : reducerCanvas.document.layers, + selectedLayerId: mutation.selectedLayerId, + }, + }; + expect(reducerAccepted()).toBe(true); + expect(mirrorAccepted()).toBe(false); + mirrorDocument = reducerCanvas.document; + expect(mirrorAccepted()).toBe(true); + return; + } + expect(mutation).toMatchObject({ selectedImageIndex: 0, type: 'commitStagedImage' }); + if (mutation.type !== 'commitStagedImage') { + throw new Error('unexpected mutation'); + } + const layer = mutation.layer; + reducerCanvas = { + ...reducerCanvas, + document: { ...reducerCanvas.document, layers: [layer], selectedLayerId: layer.id }, + stagingArea: { ...reducerCanvas.stagingArea, isVisible: false, pendingImageIds: [], pendingImages: [] }, + }; + mirrorDocument = reducerCanvas.document; + expect(reducerAccepted()).toBe(true); + expect(mirrorAccepted()).toBe(true); + } + ); + const controller = new StagedResultController({ + capturePermit: () => ({ epoch: 1 }), + createEventId: () => 'event-1', + createLayerId: () => 'layer-1', + dispatchPrepared, + endBurst: vi.fn(), + getCanvasState: () => reducerCanvas, + getDocument: () => mirrorDocument, + history, + isGestureActive: () => false, + isPermitCurrent: () => true, + now: () => '2026-07-16T01:00:00.000Z', + }); + + expect(controller.commit(selection)).toEqual({ status: 'committed', layerId: 'layer-1' }); + expect(reducerCanvas.document.layers[0]).toMatchObject({ + id: 'layer-1', + opacity: 0.5, + source: { image: { imageName: 'result.png' }, type: 'image' }, + transform: { scaleX: 2, scaleY: 2, x: 12, y: 18 }, + type: 'raster', + }); + expect(history.canUndo()).toBe(true); + + history.undo(); + expect(reducerCanvas.document.layers).toEqual([]); + expect(reducerCanvas.document.selectedLayerId).toBeNull(); + expect(reducerCanvas.stagingArea.pendingImages).toEqual([]); + + history.redo(); + expect(reducerCanvas.document.layers[0]).toBe(reducerCanvas.document.layers[0]); + expect(reducerCanvas.document.layers[0]?.id).toBe('layer-1'); + expect(reducerCanvas.stagingArea.pendingImages).toEqual([]); + }); + + it('returns stale without history when the selected candidate changes before reducer acceptance', () => { + const canvas = makeCanvas(); + const history = createHistory(); + const controller = new StagedResultController({ + capturePermit: () => ({ epoch: 1 }), + createEventId: () => 'event-1', + createLayerId: () => 'layer-1', + dispatchPrepared: () => { + throw new Error('reducer rejected stale candidate'); + }, + endBurst: vi.fn(), + getCanvasState: () => canvas, + getDocument: () => canvas.document, + history, + isGestureActive: () => false, + isPermitCurrent: () => true, + now: () => '2026-07-16T01:00:00.000Z', + }); + + expect(controller.commit(selection)).toEqual({ status: 'stale' }); + expect(canvas.document.layers).toEqual([]); + expect(canvas.stagingArea.pendingImages).toEqual([candidate]); + expect(history.canUndo()).toBe(false); + }); + + it.each([ + { gestureActive: false, name: 'edit lease is unavailable', permit: null, permitCurrent: false }, + { gestureActive: true, name: 'a gesture is active', permit: { epoch: 1 }, permitCurrent: true }, + { gestureActive: false, name: 'the captured edit permit is stale', permit: { epoch: 1 }, permitCurrent: false }, + ])('returns busy without mutation when $name', ({ gestureActive, permit, permitCurrent }) => { + const canvas = makeCanvas(); + const dispatchPrepared = vi.fn(); + const history = createHistory(); + const controller = new StagedResultController({ + capturePermit: () => permit, + createEventId: () => 'event-1', + createLayerId: () => 'layer-1', + dispatchPrepared, + endBurst: vi.fn(), + getCanvasState: () => canvas, + getDocument: () => canvas.document, + history, + isGestureActive: () => gestureActive, + isPermitCurrent: () => permitCurrent, + now: () => '2026-07-16T01:00:00.000Z', + }); + + expect(controller.commit(selection)).toEqual({ status: 'busy' }); + expect(dispatchPrepared).not.toHaveBeenCalled(); + expect(history.canUndo()).toBe(false); + }); + + it.each(['reducer', 'mirror'])('does not record history when %s acceptance rejects the commit', () => { + const canvas = makeCanvas(); + const history = createHistory(); + const controller = new StagedResultController({ + capturePermit: () => ({ epoch: 1 }), + createEventId: () => 'event-1', + createLayerId: () => 'layer-1', + dispatchPrepared: () => { + throw new Error('acceptance rejected'); + }, + endBurst: vi.fn(), + getCanvasState: () => canvas, + getDocument: () => canvas.document, + history, + isGestureActive: () => false, + isPermitCurrent: () => true, + now: () => '2026-07-16T01:00:00.000Z', + }); + + expect(controller.commit(selection)).toEqual({ status: 'stale' }); + expect(canvas.stagingArea.pendingImages).toEqual([candidate]); + expect(history.canUndo()).toBe(false); + }); + + it('clears the redo stack after a new staged image commit', () => { + let reducerCanvas = makeCanvas(); + let mirrorDocument = reducerCanvas.document; + const history = createHistory(); + history.push({ bytes: 1, label: 'older edit', redo: vi.fn(), undo: vi.fn() }); + history.undo(); + expect(history.canRedo()).toBe(true); + const controller = new StagedResultController({ + capturePermit: () => ({ epoch: 1 }), + createEventId: () => 'event-1', + createLayerId: () => 'layer-1', + dispatchPrepared: (mutation, reducerAccepted, mirrorAccepted) => { + if (mutation.type !== 'commitStagedImage') { + throw new Error('unexpected mutation'); + } + reducerCanvas = { + ...reducerCanvas, + document: { + ...reducerCanvas.document, + layers: [mutation.layer], + selectedLayerId: mutation.layer.id, + }, + stagingArea: { + ...reducerCanvas.stagingArea, + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + }; + expect(reducerAccepted()).toBe(true); + mirrorDocument = reducerCanvas.document; + expect(mirrorAccepted()).toBe(true); + }, + endBurst: vi.fn(), + getCanvasState: () => reducerCanvas, + getDocument: () => mirrorDocument, + history, + isGestureActive: () => false, + isPermitCurrent: () => true, + now: () => '2026-07-16T01:00:00.000Z', + }); + + expect(controller.commit(selection).status).toBe('committed'); + expect(history.canRedo()).toBe(false); + }); + + it.each([ + { canvas: null, name: 'project', options: selection }, + { + canvas: makeCanvas(), + name: 'candidate', + options: { candidate: { ...candidate, imageName: 'missing.png' }, selectedImageIndex: 0 }, + }, + ])('returns missing without mutation when the $name is absent', ({ canvas, options }) => { + const dispatchPrepared = vi.fn(); + const history = createHistory(); + const controller = new StagedResultController({ + capturePermit: () => ({ epoch: 1 }), + createEventId: () => 'event-1', + createLayerId: () => 'layer-1', + dispatchPrepared, + endBurst: vi.fn(), + getCanvasState: () => canvas, + getDocument: () => canvas?.document ?? null, + history, + isGestureActive: () => false, + isPermitCurrent: () => true, + now: () => '2026-07-16T01:00:00.000Z', + }); + + expect(controller.commit(options)).toEqual({ status: 'missing' }); + expect(dispatchPrepared).not.toHaveBeenCalled(); + expect(history.canUndo()).toBe(false); + }); + + it('rejects retained commit capabilities after disposal', () => { + const canvas = makeCanvas(); + const dispatchPrepared = vi.fn(); + const history = createHistory(); + const controller = new StagedResultController({ + capturePermit: () => ({ epoch: 1 }), + createEventId: () => 'event-1', + createLayerId: () => 'layer-1', + dispatchPrepared, + endBurst: vi.fn(), + getCanvasState: () => canvas, + getDocument: () => canvas.document, + history, + isGestureActive: () => false, + isPermitCurrent: () => true, + now: () => '2026-07-16T01:00:00.000Z', + }); + + controller.dispose(); + + expect(controller.commit(selection)).toEqual({ status: 'missing' }); + expect(dispatchPrepared).not.toHaveBeenCalled(); + expect(history.canUndo()).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/stagedResultController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/stagedResultController.ts new file mode 100644 index 00000000000..53648c52370 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/stagedResultController.ts @@ -0,0 +1,211 @@ +import type { CommitStagedImageOptions, CommitStagedImageResult } from '@workbench/canvas-engine/api'; +import type { History } from '@workbench/canvas-engine/history/history'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { + CanvasDocumentContractV2, + CanvasRasterLayerContractV2, + CanvasStagingCandidateContract, + CanvasStateContractV2, + ProjectEvent, +} from '@workbench/types'; + +import { getCanvasStagingCandidateFingerprint } from '@workbench/canvasStagingView'; + +export interface StagedResultControllerOptions { + readonly capturePermit: (owner?: Owner) => Permit | null; + readonly createEventId: () => string; + readonly createLayerId: () => string; + readonly dispatchPrepared: ( + mutation: CanvasProjectMutation, + reducerAccepted: () => boolean, + mirrorAccepted: () => boolean + ) => void; + readonly endBurst: () => void; + readonly getCanvasState: () => CanvasStateContractV2 | null; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly history: History; + readonly isGestureActive: () => boolean; + readonly isPermitCurrent: (permit: Permit) => boolean; + readonly now: () => string; +} + +const createLayer = ( + id: string, + name: string, + candidate: CanvasStagingCandidateContract +): CanvasRasterLayerContractV2 => { + const { placement } = candidate; + return { + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name, + opacity: placement.opacity, + source: { + image: { height: candidate.height, imageName: candidate.imageName, width: candidate.width }, + type: 'image', + }, + transform: { + rotation: 0, + scaleX: candidate.width === 0 ? 1 : placement.width / candidate.width, + scaleY: candidate.height === 0 ? 1 : placement.height / candidate.height, + x: placement.x, + y: placement.y, + }, + type: 'raster', + }; +}; + +/** Owns guarded, project-bound acceptance of staged canvas results. */ +export class StagedResultController { + private disposed = false; + + constructor(private readonly options: StagedResultControllerOptions) {} + + commit(options: CommitStagedImageOptions, owner?: Owner): CommitStagedImageResult { + const o = this.options; + if (this.disposed) { + return { status: 'missing' }; + } + const permit = o.capturePermit(owner); + if (!permit || o.isGestureActive()) { + return { status: 'busy' }; + } + const canvas = o.getCanvasState(); + if (!canvas) { + return { status: 'missing' }; + } + const candidateFingerprint = getCanvasStagingCandidateFingerprint(options.candidate); + if ( + !canvas.stagingArea.pendingImages.some( + (pending) => getCanvasStagingCandidateFingerprint(pending) === candidateFingerprint + ) + ) { + return { status: 'missing' }; + } + if (!o.isPermitCurrent(permit) || o.isGestureActive()) { + return { status: 'busy' }; + } + + const layer = createLayer(o.createLayerId(), `Layer ${canvas.document.layers.length + 1}`, options.candidate); + const event: ProjectEvent = { + createdAt: o.now(), + id: o.createEventId(), + summary: `Accepted ${options.candidate.imageName} into a new raster layer`, + type: 'canvas-layer-accepted', + }; + const previousSelectedLayerId = canvas.document.selectedLayerId; + const previousLayers = canvas.document.layers; + const acceptedLayers = [layer, ...previousLayers]; + const previousStagingArea = structuredClone(canvas.stagingArea); + const hasPreviousLayerStack = (document: CanvasDocumentContractV2 | null): boolean => + document?.selectedLayerId === previousSelectedLayerId && + document.layers.length === previousLayers.length && + document.layers.every((current, index) => current === previousLayers[index]); + const hasAcceptedLayerStack = (document: CanvasDocumentContractV2 | null): boolean => + document?.selectedLayerId === layer.id && + document.layers.length === acceptedLayers.length && + document.layers.every((current, index) => current === acceptedLayers[index]); + const isCommitted = (next: CanvasStateContractV2 | null): boolean => + next?.document.selectedLayerId === layer.id && + next.document.layers.some((current) => current === layer) && + next.stagingArea.pendingImages.length === 0 && + next.stagingArea.pendingImageIds.length === 0 && + next.stagingArea.selectedImageIndex === 0 && + !next.stagingArea.isVisible; + const isMirrored = (): boolean => + o.getDocument()?.selectedLayerId === layer.id && + o.getDocument()?.layers.some((current) => current === layer) === true; + + try { + o.endBurst(); + o.dispatchPrepared( + { + candidateFingerprint, + event, + layer, + selectedImageIndex: options.selectedImageIndex, + type: 'commitStagedImage', + }, + () => isCommitted(o.getCanvasState()), + isMirrored + ); + } catch { + if (isCommitted(o.getCanvasState())) { + o.dispatchPrepared( + { + event, + layer, + selectedLayerId: previousSelectedLayerId, + stagingArea: previousStagingArea, + type: 'rollbackStagedImageCommit', + }, + () => + hasPreviousLayerStack(o.getCanvasState()?.document ?? null) && + o.getCanvasState()?.stagingArea === previousStagingArea, + () => hasPreviousLayerStack(o.getDocument()) + ); + } + return { status: 'stale' }; + } + + const applyLayerStack = ( + mutation: Extract, + reducerAccepted: () => boolean, + mirrorAccepted: () => boolean, + rollback: Extract, + reducerRolledBack: () => boolean, + mirrorRolledBack: () => boolean + ): void => { + try { + o.dispatchPrepared(mutation, reducerAccepted, mirrorAccepted); + } catch (error) { + if (reducerAccepted()) { + o.dispatchPrepared(rollback, reducerRolledBack, mirrorRolledBack); + } + throw error; + } + }; + const addAcceptedLayer: Extract = { + add: { index: 0, layers: [layer] }, + enabledUpdates: [], + selectedLayerId: layer.id, + type: 'applyCanvasLayerStackMutation', + }; + const removeAcceptedLayer: Extract = { + enabledUpdates: [], + removeIds: [layer.id], + selectedLayerId: previousSelectedLayerId, + type: 'applyCanvasLayerStackMutation', + }; + o.history.push({ + bytes: 256, + label: 'Accept staged image', + redo: () => + applyLayerStack( + addAcceptedLayer, + () => hasAcceptedLayerStack(o.getCanvasState()?.document ?? null), + () => hasAcceptedLayerStack(o.getDocument()), + removeAcceptedLayer, + () => hasPreviousLayerStack(o.getCanvasState()?.document ?? null), + () => hasPreviousLayerStack(o.getDocument()) + ), + replayFailureAtomic: true, + undo: () => + applyLayerStack( + removeAcceptedLayer, + () => hasPreviousLayerStack(o.getCanvasState()?.document ?? null), + () => hasPreviousLayerStack(o.getDocument()), + addAcceptedLayer, + () => hasAcceptedLayerStack(o.getCanvasState()?.document ?? null), + () => hasAcceptedLayerStack(o.getDocument()) + ), + }); + return { layerId: layer.id, status: 'committed' }; + } + + dispose(): void { + this.disposed = true; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/structuralLayerController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/structuralLayerController.test.ts new file mode 100644 index 00000000000..36bb7244e20 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/structuralLayerController.test.ts @@ -0,0 +1,112 @@ +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { createHistory } from '@workbench/canvas-engine/history/history'; +import { describe, expect, it, vi } from 'vitest'; + +import { StructuralLayerController } from './structuralLayerController'; + +describe('StructuralLayerController', () => { + it('reports whether a structural commit is currently allowed and committed', () => { + const dispatch = vi.fn(); + const history = createHistory(); + const controller = new StructuralLayerController({ + canEdit: () => true, + dispatch, + getDocument: () => null, + history, + isGestureActive: () => false, + }); + const forward = { id: 'layer', type: 'setCanvasSelectedLayer' } as const; + const inverse = { id: null, type: 'setCanvasSelectedLayer' } as const; + + expect(controller.canCommit()).toBe(true); + expect(controller.commit('Select layer', forward, inverse)).toBe(true); + expect(dispatch).toHaveBeenCalledOnce(); + expect(history.canUndo()).toBe(true); + }); + + it.each([ + { canEdit: false, gestureActive: false, reason: 'editing is disallowed' }, + { canEdit: true, gestureActive: true, reason: 'a gesture is active' }, + ])('reports and refuses a structural commit when $reason', ({ canEdit, gestureActive }) => { + const dispatch = vi.fn(); + const history = createHistory(); + const controller = new StructuralLayerController({ + canEdit: () => canEdit, + dispatch, + getDocument: () => null, + history, + isGestureActive: () => gestureActive, + }); + + expect(controller.canCommit()).toBe(false); + expect( + controller.commit( + 'Select layer', + { id: 'layer', type: 'setCanvasSelectedLayer' }, + { id: null, type: 'setCanvasSelectedLayer' } + ) + ).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + expect(history.canUndo()).toBe(false); + }); + + it('reports and refuses structural commits after disposal', () => { + const dispatch = vi.fn(); + const history = createHistory(); + const controller = new StructuralLayerController({ + canEdit: () => true, + dispatch, + getDocument: () => null, + history, + isGestureActive: () => false, + }); + + controller.dispose(); + + expect(controller.canCommit()).toBe(false); + expect( + controller.commit( + 'Select layer', + { id: 'layer', type: 'setCanvasSelectedLayer' }, + { id: null, type: 'setCanvasSelectedLayer' } + ) + ).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('owns guarded structural history and coalesces rapid nudges', () => { + let now = 0; + const dispatch = vi.fn(); + const history = createHistory(); + const document = { + layers: [ + { + id: 'layer', + isEnabled: true, + isLocked: false, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + }, + ], + selectedLayerId: 'layer', + } as CanvasDocumentContractV2; + const controller = new StructuralLayerController({ + canEdit: () => true, + dispatch, + getDocument: () => document, + history, + isGestureActive: () => false, + now: () => now, + }); + + controller.nudge(1, 0); + now = 100; + document.layers[0]!.transform.x = 1; + controller.nudge(1, 0); + + expect(dispatch).toHaveBeenCalledTimes(2); + history.undo(); + expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ patch: { transform: { x: 0, y: 0 } } })); + expect(history.canUndo()).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/structuralLayerController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/structuralLayerController.ts new file mode 100644 index 00000000000..c4e364b4e4f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/structuralLayerController.ts @@ -0,0 +1,97 @@ +import type { History } from '@workbench/canvas-engine/history/history'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { createDocumentPatchEntry } from '@workbench/canvas-engine/history/documentPatch'; + +export interface StructuralLayerControllerOptions { + readonly history: History; + readonly dispatch: (action: CanvasProjectMutation) => void; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly canEdit: () => boolean; + readonly isGestureActive: () => boolean; + readonly now?: () => number; +} + +interface NudgeBurst { + expiresAt: number; + layerId: string; + origin: { x: number; y: number }; +} + +const NUDGE_COALESCE_MS = 500; + +/** Owns guarded structural document edits and nudge coalescing. */ +export class StructuralLayerController { + private burst: NudgeBurst | null = null; + private disposed = false; + private readonly now: () => number; + + constructor(private readonly deps: StructuralLayerControllerOptions) { + this.now = deps.now ?? Date.now; + } + + endBurst(): void { + this.burst = null; + } + + canCommit(): boolean { + return !this.disposed && this.deps.canEdit() && !this.deps.isGestureActive(); + } + + commit(label: string, forward: CanvasProjectMutation, inverse: CanvasProjectMutation): boolean { + if (!this.canCommit()) { + return false; + } + this.endBurst(); + this.deps.dispatch(forward); + this.deps.history.push(createDocumentPatchEntry({ dispatch: this.deps.dispatch, forward, inverse, label })); + return true; + } + + preview(action: CanvasProjectMutation): boolean { + if (this.disposed || !this.deps.canEdit() || this.deps.isGestureActive()) { + return false; + } + this.deps.dispatch(action); + return true; + } + + nudge(dx: number, dy: number): void { + if (this.disposed || !this.deps.canEdit() || this.deps.isGestureActive()) { + return; + } + const document = this.deps.getDocument(); + const layer = document?.layers.find((candidate) => candidate.id === document.selectedLayerId); + if (!document?.selectedLayerId || !layer || layer.isLocked || !layer.isEnabled) { + return; + } + const next = { x: layer.transform.x + dx, y: layer.transform.y + dy }; + const now = this.now(); + const coalesce = !!this.burst && this.burst.layerId === layer.id && now < this.burst.expiresAt; + const origin = coalesce && this.burst ? this.burst.origin : { x: layer.transform.x, y: layer.transform.y }; + const forward: CanvasProjectMutation = { + id: layer.id, + patch: { transform: next }, + type: 'updateCanvasLayer', + }; + const inverse: CanvasProjectMutation = { + id: layer.id, + patch: { transform: origin }, + type: 'updateCanvasLayer', + }; + this.deps.dispatch(forward); + const entry = createDocumentPatchEntry({ dispatch: this.deps.dispatch, forward, inverse, label: 'Nudge layer' }); + if (coalesce) { + this.deps.history.amendLast(entry); + } else { + this.deps.history.push(entry); + } + this.burst = { expiresAt: now + NUDGE_COALESCE_MS, layerId: layer.id, origin }; + } + + dispose(): void { + this.disposed = true; + this.endBurst(); + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/textEditingController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/textEditingController.test.ts new file mode 100644 index 00000000000..0c2bd9f3679 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/textEditingController.test.ts @@ -0,0 +1,51 @@ +import type { TextEditSession } from '@workbench/canvas-engine/engineStores'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { DEFAULT_TEXT_OPTIONS } from '@workbench/canvas-engine/engineStores'; +import { describe, expect, it, vi } from 'vitest'; + +import { TextEditingController } from './textEditingController'; + +const createHarness = (document: CanvasDocumentContractV2) => { + let session: TextEditSession | null = null; + const commitStructural = vi.fn(); + const invalidate = vi.fn(); + const controller = new TextEditingController({ + canEdit: () => true, + commitStructural, + createLayerId: () => 'text-new', + getDocument: () => document, + invalidate, + isGestureActive: () => false, + options: { get: () => DEFAULT_TEXT_OPTIONS }, + session: { + get: () => session, + set: (value) => (session = value), + }, + }); + return { commitStructural, controller, getSession: () => session, invalidate }; +}; + +describe('TextEditingController', () => { + it('owns create session state and commits one structural layer addition', () => { + const h = createHarness({ bbox: { height: 1, width: 1, x: 0, y: 0 }, height: 1, layers: [], width: 1 } as never); + h.controller.openCreate({ x: 10.4, y: 20.6 }); + expect(h.getSession()?.transform).toMatchObject({ x: 10, y: 21 }); + + h.controller.commit('hello'); + expect(h.getSession()).toBeNull(); + expect(h.commitStructural).toHaveBeenCalledOnce(); + expect(h.commitStructural.mock.calls[0]?.[0]).toBe('Add text'); + }); + + it('uses the registered content reader and cancels empty creates', () => { + const h = createHarness({ bbox: { height: 1, width: 1, x: 0, y: 0 }, height: 1, layers: [], width: 1 } as never); + h.controller.openCreate({ x: 0, y: 0 }); + h.controller.setContentReader(() => ' '); + + expect(h.controller.commitOpen()).toBe(true); + expect(h.getSession()).toBeNull(); + expect(h.commitStructural).not.toHaveBeenCalled(); + expect(h.invalidate).toHaveBeenCalledWith({ overlay: true }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/textEditingController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/textEditingController.ts new file mode 100644 index 00000000000..ee93ab402da --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/textEditingController.ts @@ -0,0 +1,186 @@ +import type { TextEditSession, TextSource, TextToolOptions } from '@workbench/canvas-engine/engineStores'; +import type { Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +export interface TextEditingControllerOptions { + readonly session: { + get(): TextEditSession | null; + set(value: TextEditSession | null): void; + }; + readonly options: { get(): TextToolOptions }; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly canEdit: () => boolean; + readonly isGestureActive: () => boolean; + readonly createLayerId: () => string; + readonly commitStructural: (label: string, forward: CanvasProjectMutation, inverse: CanvasProjectMutation) => void; + readonly invalidate: (payload: { layers?: string[]; overlay?: true }) => void; +} + +const sourcesEqual = (left: TextSource, right: TextSource): boolean => + left.content === right.content && + left.fontFamily === right.fontFamily && + left.fontSize === right.fontSize && + left.fontWeight === right.fontWeight && + left.lineHeight === right.lineHeight && + left.align === right.align && + left.color === right.color; + +/** Owns create/edit text session state and its structural commits. */ +export class TextEditingController { + private sessionId = 0; + private contentReader: (() => string) | null = null; + private disposed = false; + + constructor(private readonly deps: TextEditingControllerOptions) {} + + private sourceFromOptions(content: string): TextSource { + const options = this.deps.options.get(); + return { + align: options.align, + color: options.color, + content, + fontFamily: options.fontFamily, + fontSize: options.fontSize, + fontWeight: options.fontWeight, + lineHeight: options.lineHeight, + type: 'text', + }; + } + + setContentReader(reader: (() => string) | null): void { + this.contentReader = reader; + } + + openCreate(point: Vec2): void { + if (this.disposed || !this.deps.getDocument()) { + return; + } + this.deps.session.set({ + id: ++this.sessionId, + layerId: null, + mode: 'create', + source: this.sourceFromOptions(''), + startSource: null, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: Math.round(point.x), y: Math.round(point.y) }, + }); + this.deps.invalidate({ overlay: true }); + } + + openEdit(layerId: string): void { + if (this.disposed) { + return; + } + const document = this.deps.getDocument(); + const layer = document?.layers.find((candidate) => candidate.id === layerId); + if ( + !document || + !layer || + layer.type !== 'raster' || + layer.source.type !== 'text' || + !layer.isEnabled || + layer.isLocked + ) { + return; + } + this.deps.session.set({ + id: ++this.sessionId, + layerId, + mode: 'edit', + source: { ...layer.source }, + startSource: { ...layer.source }, + transform: { ...layer.transform }, + }); + this.deps.invalidate({ layers: [layerId] }); + } + + updateStyle(patch: Partial): void { + const session = this.deps.session.get(); + if (this.disposed || !session) { + return; + } + this.deps.session.set({ ...session, source: { ...session.source, ...patch } }); + } + + cancel(): void { + const session = this.deps.session.get(); + if (!session) { + return; + } + this.deps.session.set(null); + this.deps.invalidate(session.layerId ? { layers: [session.layerId] } : { overlay: true }); + } + + commit(content: string, styleChanges?: Partial): void { + if (this.disposed || !this.deps.canEdit() || this.deps.isGestureActive()) { + return; + } + const session = this.deps.session.get(); + if (!session) { + return; + } + const finalSource: TextSource = { ...session.source, ...styleChanges, content }; + if (session.mode === 'create') { + if (content.trim() === '') { + this.cancel(); + return; + } + const layerId = this.deps.createLayerId(); + const layer: CanvasLayerContract = { + blendMode: 'normal', + id: layerId, + isEnabled: true, + isLocked: false, + name: `Text ${(this.deps.getDocument()?.layers.length ?? 0) + 1}`, + opacity: 1, + source: finalSource, + transform: session.transform, + type: 'raster', + }; + this.deps.session.set(null); + this.deps.commitStructural( + 'Add text', + { index: 0, layer, type: 'addCanvasLayer' }, + { ids: [layerId], type: 'removeCanvasLayers' } + ); + this.deps.invalidate({ overlay: true }); + return; + } + const { layerId, startSource } = session; + if (!layerId || !startSource) { + this.cancel(); + return; + } + this.deps.session.set(null); + if (sourcesEqual(startSource, finalSource)) { + this.deps.invalidate({ layers: [layerId] }); + return; + } + this.deps.commitStructural( + 'Edit text', + { id: layerId, source: finalSource, type: 'updateCanvasLayerSource' }, + { id: layerId, source: startSource, type: 'updateCanvasLayerSource' } + ); + } + + commitOpen(): boolean { + if (this.disposed || !this.deps.canEdit()) { + return false; + } + const session = this.deps.session.get(); + if (!session) { + return false; + } + this.commit(this.contentReader ? this.contentReader() : session.source.content); + return true; + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.contentReader = null; + this.deps.session.set(null); + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/thumbnailController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/thumbnailController.test.ts new file mode 100644 index 00000000000..6b064d5ad0c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/thumbnailController.test.ts @@ -0,0 +1,50 @@ +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it, vi } from 'vitest'; + +import { ThumbnailController } from './thumbnailController'; + +describe('ThumbnailController budget', () => { + it('returns over-budget before rasterizing a missing thumbnail cache', async () => { + const layer = { + blendMode: 'normal' as const, + id: 'layer', + isEnabled: true, + isLocked: false, + name: 'Layer', + opacity: 1, + source: { image: { height: 100, imageName: 'layer.png', width: 100 }, type: 'image' as const }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster' as const, + }; + const document: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [layer], + selectedLayerId: null, + version: 2, + width: 100, + }; + const rasterize = vi.fn(); + const controller = new ThumbnailController({ + backend: createTestStubRasterBackend(), + getActiveProjectId: () => 'p', + getCheckerboard: vi.fn(), + getDocument: () => document, + getEntry: () => undefined, + getMaskPattern: () => null, + isDisposed: () => false, + isSupportedSource: () => true, + projectId: 'p', + rasterize, + reportError: vi.fn(), + reserve: () => ({ availableBytes: 0, requestedBytes: 80_000, status: 'over-budget' }), + setStatus: vi.fn(), + }); + + await expect(controller.request('layer')).resolves.toBe('over-budget'); + expect(rasterize).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/thumbnailController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/thumbnailController.ts new file mode 100644 index 00000000000..6afe026dffe --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/thumbnailController.ts @@ -0,0 +1,152 @@ +import type { LayerThumbnailRequestResult } from '@workbench/canvas-engine/api'; +import type { LayerCacheEntry } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasDocumentContractV2, CanvasLayerContract, CanvasLayerSourceContract } from '@workbench/types'; + +import { getSourceContentRect, renderableSourceOf } from '@workbench/canvas-engine/document/sources'; +import { applyAdjustments, isIdentityAdjustments } from '@workbench/canvas-engine/render/adjustments'; +import { renderControlTransparency } from '@workbench/canvas-engine/render/controlTransparency'; +import { colorizeMask } from '@workbench/canvas-engine/render/maskFill'; +import { fitThumbnailSize } from '@workbench/canvas-engine/render/thumbnail'; + +export interface ThumbnailControllerOptions { + readonly backend: RasterBackend; + readonly projectId: string; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly getActiveProjectId: () => string | null; + readonly getEntry: (layerId: string) => LayerCacheEntry | undefined; + readonly getCheckerboard: () => RasterSurface; + readonly getMaskPattern: (style: string, color: string) => RasterSurface | null; + readonly isDisposed: () => boolean; + readonly isSupportedSource: (source: CanvasLayerSourceContract) => boolean; + readonly rasterize: ( + layer: CanvasLayerContract, + document: CanvasDocumentContractV2 + ) => Promise<'published' | 'stale' | 'error'>; + readonly setStatus: (layerId: string, status: 'loading' | 'ready' | 'error' | null) => void; + readonly reportError: (layerId: string, error: unknown) => void; + readonly reserve?: ( + bytes: number + ) => + | { status: 'ok'; lease: { release(): void } } + | { status: 'over-budget'; requestedBytes: number; availableBytes: number }; + readonly pin?: (layerId: string) => { release(): void }; +} + +/** Owns bounded thumbnail rendering and lazy rasterization status. */ +export class ThumbnailController { + private disposed = false; + + constructor(private readonly deps: ThumbnailControllerOptions) {} + + draw(layerId: string, target: HTMLCanvasElement, maxSize: number): boolean { + if (this.disposed) { + return false; + } + const entry = this.deps.getEntry(layerId); + const layer = this.deps.getDocument()?.layers.find((candidate) => candidate.id === layerId); + if (!entry?.hasPublishedPixels || !layer) { + return false; + } + const { height, width } = fitThumbnailSize(entry.surface.width, entry.surface.height, maxSize); + const context = target.getContext('2d'); + if (width === 0 || height === 0 || !context) { + return false; + } + const reservation = this.deps.reserve?.(width * height * 12); + if (reservation?.status === 'over-budget') { + return false; + } + const pinLease = this.deps.pin?.(layerId); + try { + target.width = width; + target.height = height; + context.clearRect(0, 0, width, height); + const thumbnail = this.deps.backend.createSurface(width, height); + thumbnail.ctx.setTransform(1, 0, 0, 1, 0, 0); + thumbnail.ctx.clearRect(0, 0, width, height); + thumbnail.ctx.globalAlpha = 1; + thumbnail.ctx.globalCompositeOperation = 'source-over'; + thumbnail.ctx.drawImage(entry.surface.canvas, 0, 0, width, height); + const checker = context.createPattern(this.deps.getCheckerboard().canvas as CanvasImageSource, 'repeat'); + if (checker) { + context.fillStyle = checker; + context.fillRect(0, 0, width, height); + } + let display = thumbnail; + if (layer.type === 'raster' && !isIdentityAdjustments(layer.adjustments)) { + const pixels = thumbnail.ctx.getImageData(0, 0, width, height); + applyAdjustments(pixels, layer.adjustments); + thumbnail.ctx.putImageData(pixels, 0, 0); + } else if (layer.type === 'control' && layer.withTransparencyEffect) { + display = renderControlTransparency(this.deps.backend, thumbnail, width, height); + } else if (layer.type === 'inpaint_mask' || layer.type === 'regional_guidance') { + const { fill } = layer.mask; + display = colorizeMask( + this.deps.backend, + thumbnail, + width, + height, + fill, + this.deps.getMaskPattern(fill.style, fill.color) + ); + } + context.globalAlpha = layer.opacity; + context.drawImage(display.canvas as CanvasImageSource, 0, 0); + return true; + } finally { + pinLease?.release(); + if (reservation?.status === 'ok') { + reservation.lease.release(); + } + } + } + + async request(layerId: string): Promise { + if (this.disposed || this.deps.isDisposed() || this.deps.getActiveProjectId() !== this.deps.projectId) { + this.deps.setStatus(layerId, null); + return this.disposed || this.deps.isDisposed() ? 'missing' : 'stale'; + } + const document = this.deps.getDocument(); + const layer = document?.layers.find((candidate) => candidate.id === layerId); + if (!document || !layer) { + this.deps.setStatus(layerId, null); + return 'missing'; + } + const source = renderableSourceOf(layer); + if (!source || !this.deps.isSupportedSource(source)) { + this.deps.setStatus(layerId, null); + return 'unsupported'; + } + const entry = this.deps.getEntry(layerId); + if (entry?.hasPublishedPixels && !entry.stale) { + this.deps.setStatus(layerId, 'ready'); + return 'ready'; + } + const rect = getSourceContentRect(layer, document); + const reservation = this.deps.reserve?.(rect.width * rect.height * 8); + if (reservation?.status === 'over-budget') { + this.deps.setStatus(layerId, null); + return 'over-budget'; + } + const pinLease = this.deps.pin?.(layerId); + this.deps.setStatus(layerId, 'loading'); + try { + const result = await this.deps.rasterize(layer, document); + return result === 'published' ? 'ready' : result; + } catch (error) { + this.deps.setStatus(layerId, 'error'); + this.deps.reportError(layerId, error); + return 'error'; + } finally { + pinLease?.release(); + if (reservation?.status === 'ok') { + reservation.lease.release(); + } + } + } + + dispose(): void { + this.disposed = true; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/transformEditingController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/transformEditingController.test.ts new file mode 100644 index 00000000000..ee787c732a3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/transformEditingController.test.ts @@ -0,0 +1,61 @@ +import type { TransformSession } from '@workbench/canvas-engine/engineStores'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it, vi } from 'vitest'; + +import { TransformEditingController } from './transformEditingController'; + +const document = { + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'normal', + id: 'shape', + isEnabled: true, + isLocked: false, + name: 'Shape', + opacity: 1, + source: { fill: '#fff', height: 10, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 10 }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + width: 100, +} as CanvasDocumentContractV2; + +describe('TransformEditingController', () => { + it('owns preview state and commits parametric transforms through declared ports', () => { + let session: TransformSession | null = null; + const dispatch = vi.fn(); + const pushHistory = vi.fn(); + const setOverride = vi.fn(); + const controller = new TransformEditingController({ + backend: createTestStubRasterBackend(), + canEdit: () => true, + dispatch, + endBurst: vi.fn(), + getCache: () => null, + getDocument: () => document, + invalidate: vi.fn(), + isGestureActive: () => false, + pushHistory, + replaceCache: vi.fn(), + restoreCache: vi.fn(), + session: { get: () => session, set: (value) => (session = value) }, + setOverride, + }); + + controller.begin('shape'); + controller.update({ rotation: 0, scaleX: 1, scaleY: 1, x: 12, y: 4 }); + controller.apply(); + + expect(session).toBeNull(); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ id: 'shape', patch: { transform: expect.objectContaining({ x: 12, y: 4 }) } }) + ); + expect(pushHistory).toHaveBeenCalledOnce(); + expect(setOverride).toHaveBeenLastCalledWith('shape', null); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/transformEditingController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/transformEditingController.ts new file mode 100644 index 00000000000..03dcecd906a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/transformEditingController.ts @@ -0,0 +1,199 @@ +import type { TransformSession } from '@workbench/canvas-engine/engineStores'; +import type { HistoryEntry } from '@workbench/canvas-engine/history/history'; +import type { LayerCacheEntry } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { LayerTransform } from '@workbench/canvas-engine/transform/transformMath'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { isRenderableLayer } from '@workbench/canvas-engine/document/sources'; +import { createDocumentPatchEntry } from '@workbench/canvas-engine/history/documentPatch'; +import { isEmpty, roundOut, transformBounds } from '@workbench/canvas-engine/math/rect'; +import { hittableLayerSize } from '@workbench/canvas-engine/tools/moveHitTest'; +import { bakeMatrix } from '@workbench/canvas-engine/transform/transformMath'; + +export interface TransformEditingControllerOptions { + readonly session: { get(): TransformSession | null; set(value: TransformSession | null): void }; + readonly backend: RasterBackend; + readonly getDocument: () => CanvasDocumentContractV2 | null; + readonly getCache: (layerId: string) => LayerCacheEntry | null; + readonly setOverride: (layerId: string, transform: LayerTransform | null) => void; + readonly replaceCache: (layerId: string, rect: Rect, surface: RasterSurface) => void; + readonly restoreCache: (layerId: string, rect: Rect, pixels: ImageData) => void; + readonly dispatch: (action: CanvasProjectMutation) => void; + readonly pushHistory: (entry: HistoryEntry) => void; + readonly canEdit: () => boolean; + readonly isGestureActive: () => boolean; + readonly endBurst: () => void; + readonly invalidate: (payload: { layers: string[]; overlay: true }) => void; +} + +const unchanged = (left: LayerTransform, right: LayerTransform): boolean => + left.x === right.x && + left.y === right.y && + left.scaleX === right.scaleX && + left.scaleY === right.scaleY && + left.rotation === right.rotation; + +/** Owns transform session state, previews, param commits, and paint bakes. */ +export class TransformEditingController { + private disposed = false; + + constructor(private readonly deps: TransformEditingControllerOptions) {} + + private clearOverride(): void { + const session = this.deps.session.get(); + if (session) { + this.deps.setOverride(session.layerId, null); + } + } + + begin(layerId: string): void { + if (this.disposed) { + return; + } + const document = this.deps.getDocument(); + const layer = document?.layers.find((candidate) => candidate.id === layerId); + if (!document || !layer || !layer.isEnabled || layer.isLocked || !hittableLayerSize(layer, document)) { + return; + } + this.clearOverride(); + const start = { ...layer.transform }; + this.deps.session.set({ layerId, startTransform: start, transform: start }); + this.deps.setOverride(layerId, start); + this.deps.invalidate({ layers: [layerId], overlay: true }); + } + + update(transform: LayerTransform): void { + const session = this.deps.session.get(); + if (this.disposed || !session) { + return; + } + this.deps.session.set({ ...session, transform }); + this.deps.setOverride(session.layerId, transform); + this.deps.invalidate({ layers: [session.layerId], overlay: true }); + } + + cancel(): void { + const session = this.deps.session.get(); + if (!session) { + return; + } + this.deps.setOverride(session.layerId, null); + this.deps.session.set(null); + this.deps.invalidate({ layers: [session.layerId], overlay: true }); + } + + private bakeEntry( + layerId: string, + beforeRect: Rect, + before: ImageData, + afterRect: Rect, + after: ImageData, + oldTransform: LayerTransform, + newTransform: LayerTransform + ): HistoryEntry { + return { + bytes: before.data.byteLength + after.data.byteLength + 256, + label: 'Transform layer', + redo: () => { + this.deps.dispatch({ id: layerId, patch: { transform: newTransform }, type: 'updateCanvasLayer' }); + this.deps.restoreCache(layerId, afterRect, after); + }, + undo: () => { + this.deps.dispatch({ id: layerId, patch: { transform: oldTransform }, type: 'updateCanvasLayer' }); + this.deps.restoreCache(layerId, beforeRect, before); + }, + }; + } + + apply(): void { + if (this.disposed || !this.deps.canEdit() || this.deps.isGestureActive()) { + return; + } + const session = this.deps.session.get(); + if (!session) { + return; + } + const document = this.deps.getDocument(); + const layer = document?.layers.find((candidate) => candidate.id === session.layerId); + const size = document && layer ? hittableLayerSize(layer, document) : null; + if ( + !document || + !layer || + !size || + !isRenderableLayer(layer) || + layer.isLocked || + unchanged(session.transform, session.startTransform) + ) { + this.cancel(); + return; + } + const source = layer.type === 'raster' || layer.type === 'control' ? layer.source : null; + if ( + source?.type === 'image' || + source?.type === 'shape' || + source?.type === 'gradient' || + source?.type === 'text' + ) { + this.deps.endBurst(); + this.deps.setOverride(session.layerId, null); + this.deps.session.set(null); + const forward: CanvasProjectMutation = { + id: session.layerId, + patch: { transform: session.transform }, + type: 'updateCanvasLayer', + }; + const inverse: CanvasProjectMutation = { + id: session.layerId, + patch: { transform: session.startTransform }, + type: 'updateCanvasLayer', + }; + this.deps.dispatch(forward); + this.deps.pushHistory( + createDocumentPatchEntry({ dispatch: this.deps.dispatch, forward, inverse, label: 'Transform layer' }) + ); + this.deps.invalidate({ layers: [session.layerId], overlay: true }); + return; + } + if (source?.type !== 'paint') { + this.cancel(); + return; + } + const cache = this.deps.getCache(layer.id); + if (!cache || isEmpty(cache.rect)) { + this.cancel(); + return; + } + this.deps.endBurst(); + const beforeRect = { ...cache.rect }; + const before = cache.surface.ctx.getImageData(0, 0, beforeRect.width, beforeRect.height); + const matrix = bakeMatrix(session.transform); + const afterRect = roundOut(transformBounds(matrix, beforeRect)); + const baked = this.deps.backend.createSurface(afterRect.width, afterRect.height); + const context = baked.ctx; + context.setTransform(1, 0, 0, 1, 0, 0); + context.clearRect(0, 0, afterRect.width, afterRect.height); + context.imageSmoothingEnabled = true; + context.setTransform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e - afterRect.x, matrix.f - afterRect.y); + context.drawImage(cache.surface.canvas, beforeRect.x, beforeRect.y); + context.setTransform(1, 0, 0, 1, 0, 0); + const after = context.getImageData(0, 0, afterRect.width, afterRect.height); + const oldTransform = { ...session.startTransform }; + const identity: LayerTransform = { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }; + this.deps.setOverride(layer.id, null); + this.deps.session.set(null); + this.deps.dispatch({ id: layer.id, patch: { transform: identity }, type: 'updateCanvasLayer' }); + this.deps.replaceCache(layer.id, afterRect, baked); + this.deps.pushHistory(this.bakeEntry(layer.id, beforeRect, before, afterRect, after, oldTransform, identity)); + } + + dispose(): void { + if (this.disposed) { + return; + } + this.cancel(); + this.disposed = true; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/diagnostics.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/diagnostics.test.ts new file mode 100644 index 00000000000..8315b7d553b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/diagnostics.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { createCanvasDiagnostics } from './diagnostics'; + +describe('createCanvasDiagnostics', () => { + it('is a stable no-op when disabled', () => { + const diagnostics = createCanvasDiagnostics(); + const before = diagnostics.snapshot(); + diagnostics.increment('surfaceCreations'); + diagnostics.add('allocatedBaseBytes', 400); + expect(diagnostics.snapshot()).toBe(before); + expect(before.surfaceCreations).toBe(0); + expect(before.allocatedBaseBytes).toBe(0); + }); + + it('records deterministic counters when enabled and resets explicitly', () => { + const diagnostics = createCanvasDiagnostics(true); + diagnostics.increment('derivedCacheHits'); + diagnostics.increment('derivedCacheHits'); + diagnostics.add('allocatedDerivedBytes', 256); + expect(diagnostics.snapshot()).toMatchObject({ allocatedDerivedBytes: 256, derivedCacheHits: 2 }); + diagnostics.reset(); + expect(diagnostics.snapshot()).toMatchObject({ allocatedDerivedBytes: 0, derivedCacheHits: 0 }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/diagnostics.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/diagnostics.ts new file mode 100644 index 00000000000..ba70fe76271 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/diagnostics.ts @@ -0,0 +1,75 @@ +export interface CanvasDiagnosticsSnapshot { + readonly surfaceCreations: number; + readonly surfaceResizes: number; + readonly allocatedBaseBytes: number; + readonly allocatedDerivedBytes: number; + readonly imageDataReads: number; + readonly imageDataWrites: number; + readonly derivedCacheHits: number; + readonly derivedCacheMisses: number; + readonly derivedCacheEvictions: number; + readonly layersConsidered: number; + readonly layersCulled: number; + readonly layersDrawn: number; + readonly compositeFrames: number; + readonly overlayFrames: number; + readonly overBudgetVisibleBaseBytes: number; +} + +export type CanvasDiagnosticsCounter = keyof CanvasDiagnosticsSnapshot; +type MutableCanvasDiagnosticsSnapshot = { -readonly [K in CanvasDiagnosticsCounter]: number }; + +export interface CanvasDiagnostics { + readonly enabled: boolean; + increment(counter: CanvasDiagnosticsCounter): void; + add(counter: CanvasDiagnosticsCounter, amount: number): void; + snapshot(): Readonly; + reset(): void; +} + +const zeroSnapshot = (): MutableCanvasDiagnosticsSnapshot => ({ + allocatedBaseBytes: 0, + allocatedDerivedBytes: 0, + compositeFrames: 0, + derivedCacheEvictions: 0, + derivedCacheHits: 0, + derivedCacheMisses: 0, + imageDataReads: 0, + imageDataWrites: 0, + layersConsidered: 0, + layersCulled: 0, + layersDrawn: 0, + overlayFrames: 0, + overBudgetVisibleBaseBytes: 0, + surfaceCreations: 0, + surfaceResizes: 0, +}); + +const DISABLED_SNAPSHOT = Object.freeze(zeroSnapshot()); + +export const createCanvasDiagnostics = (enabled = false): CanvasDiagnostics => { + if (!enabled) { + return { + add: () => undefined, + enabled: false, + increment: () => undefined, + reset: () => undefined, + snapshot: () => DISABLED_SNAPSHOT, + }; + } + + let counters = zeroSnapshot(); + return { + add: (counter, amount) => { + counters[counter] += amount; + }, + enabled: true, + increment: (counter) => { + counters[counter] += 1; + }, + reset: () => { + counters = zeroSnapshot(); + }, + snapshot: () => Object.freeze({ ...counters }), + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/bitmapStore.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/bitmapStore.test.ts new file mode 100644 index 00000000000..786ef5f5320 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/bitmapStore.test.ts @@ -0,0 +1,1136 @@ +import type { CanvasImageUploadResult } from '@workbench/canvas-engine/document/imageUpload'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasImageRef, CanvasLayerSourceContract } from '@workbench/types'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createBitmapStore } from './bitmapStore'; + +const LAYER = 'layer-1'; + +/** A resolvable deferred, so a test can hold an upload pending on demand. */ +const createDeferred = (): { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +}; + +/** Drains microtasks until `predicate` is true (or `maxTicks` is exhausted). */ +const drainUntil = async (predicate: () => boolean, maxTicks = 50): Promise => { + for (let i = 0; i < maxTicks && !predicate(); i += 1) { + await Promise.resolve(); + } +}; + +interface HarnessOptions { + encodeSurface?: (surface: RasterSurface) => Promise; + hashBlob?: (blob: Blob) => Promise; + uploadImage?: (blob: Blob) => Promise; + maxUploadAttempts?: number; + onError?: (error: unknown, layerId: string) => void; + /** The layer-local content-rect origin the surface sits at (default 0,0). */ + offset?: { x: number; y: number }; + sleep?: (ms: number) => Promise; +} + +/** The default source: a plain paint layer, matching every pre-existing test's assumption. */ +const PAINT_SOURCE: CanvasLayerSourceContract = { bitmap: null, type: 'paint' }; + +const createHarness = (options: HarnessOptions = {}) => { + const surface: RasterSurface = createTestStubRasterBackend().createSurface(10, 10); + let encoded = 'pixels-A'; + let uploadSeq = 0; + let source: CanvasLayerSourceContract | null = PAINT_SOURCE; + + let offset = options.offset ?? { x: 0, y: 0 }; + + const encodeSurface = vi.fn( + options.encodeSurface ?? (() => Promise.resolve(new Blob([encoded], { type: 'image/png' }))) + ); + const uploadImage = + options.uploadImage ?? + vi.fn( + (_blob: Blob): Promise => + Promise.resolve({ height: 10, imageName: `img-${uploadSeq++}`, width: 10 }) + ); + const dispatch = vi.fn((_action: CanvasProjectMutation) => true); + + const store = createBitmapStore({ + debounceMs: 1500, + dispatch, + encodeSurface, + getLayerSource: () => source, + getLayerSurface: () => ({ offset, surface }), + // Deterministic content hash: the encoded blob's own text. + hashBlob: options.hashBlob ?? ((blob) => blob.text()), + maxUploadAttempts: options.maxUploadAttempts ?? 3, + onError: options.onError, + retryDelaysMs: [1], + // Immediate backoff so retries don't depend on timer advancement. + sleep: options.sleep ?? (() => Promise.resolve()), + uploadImage, + }); + + return { + dispatch, + encodeSurface, + setEncoded: (value: string) => { + encoded = value; + }, + setOffset: (value: { x: number; y: number }) => { + offset = value; + }, + setSource: (value: CanvasLayerSourceContract | null) => { + source = value; + }, + store, + uploadImage: uploadImage as ReturnType, + }; +}; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('createBitmapStore', () => { + it('debounces a burst of strokes into a single flush', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(500); + h.store.markLayerDirty(LAYER); // resets the timer + await vi.advanceTimersByTimeAsync(500); + h.store.markLayerDirty(LAYER); // resets again + await vi.advanceTimersByTimeAsync(1000); // 1000 < 1500 since the last stroke + + expect(h.uploadImage).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + expect(h.uploadImage).toHaveBeenCalledTimes(1); + expect(h.dispatch).toHaveBeenCalledTimes(1); + h.store.dispose(); + }); + + it('dedupes identical pixels: the second flush reuses the image and skips the upload', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + expect(h.uploadImage).toHaveBeenCalledTimes(1); + expect(h.dispatch).toHaveBeenCalledTimes(1); + + // Same encoded pixels → same hash → dedupe hit, no second upload. + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + expect(h.uploadImage).toHaveBeenCalledTimes(1); + h.store.dispose(); + }); + + it('dispatches a same-hash re-flush when the offset changed (pure-translation persistence)', async () => { + const h = createHarness({ offset: { x: 0, y: 0 } }); + + // First flush: paint pixels at the origin → uploads img-0, document now + // points at { imageName: 'img-0', offset: { x: 0, y: 0 } }. + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + expect(h.uploadImage).toHaveBeenCalledTimes(1); + expect(h.dispatch).toHaveBeenCalledTimes(1); + h.setSource({ bitmap: { height: 10, imageName: 'img-0', width: 10 }, offset: { x: 0, y: 0 }, type: 'paint' }); + + // Transform-drag by +50px then Apply: the bake produces byte-identical + // pixels (same hash → dedupe hit, no upload) but the content sits at a new + // offset. The dispatch that persists the moved offset must still fire. + h.setOffset({ x: 50, y: 0 }); + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + // No new upload (pixels deduped), but a fresh dispatch carrying the offset. + expect(h.uploadImage).toHaveBeenCalledTimes(1); + expect(h.dispatch).toHaveBeenCalledTimes(2); + expect(h.dispatch.mock.calls.at(-1)?.[0]).toMatchObject({ + source: { bitmap: { imageName: 'img-0' }, offset: { x: 50, y: 0 }, type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + h.store.dispose(); + }); + + it('dispatches the content-rect offset alongside the bitmap ref (paint persistence round-trip)', async () => { + const h = createHarness({ offset: { x: 40, y: 25 } }); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + const dispatched = h.dispatch.mock.calls.at(-1)?.[0]; + expect(dispatched).toMatchObject({ + source: { bitmap: { imageName: 'img-0' }, offset: { x: 40, y: 25 }, type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + // The encoded blob covers only the content-sized surface (10×10 stub), so a + // reload rasterizes those pixels at the persisted offset. + expect(h.encodeSurface).toHaveBeenCalledWith(expect.objectContaining({ height: 10, width: 10 })); + h.store.dispose(); + }); + + it('routes the swap through dispatchBitmap when provided (mask persistence seam), not the default dispatch', async () => { + const surface = createTestStubRasterBackend().createSurface(10, 10); + const dispatch = vi.fn((_action: CanvasProjectMutation) => true); + const dispatchBitmap = vi.fn( + (_layerId: string, _bitmap: CanvasImageRef, _offset: { x: number; y: number }) => true + ); + const store = createBitmapStore({ + debounceMs: 1500, + dispatch, + dispatchBitmap, + encodeSurface: () => Promise.resolve(new Blob(['pixels'], { type: 'image/png' })), + getLayerSource: () => ({ bitmap: null, type: 'paint' }), + getLayerSurface: () => ({ offset: { x: 7, y: 8 }, surface }), + hashBlob: (blob) => blob.text(), + retryDelaysMs: [1], + sleep: () => Promise.resolve(), + uploadImage: () => Promise.resolve({ height: 10, imageName: 'mask-img', width: 10 }), + }); + + store.markLayerDirty('mask1'); + await vi.advanceTimersByTimeAsync(1500); + await store.flushPendingUploads(); + + // The engine-provided seam receives the ref + offset; the default paint-source + // dispatch is NOT used (the engine picks updateCanvasLayerConfig for masks). + expect(dispatchBitmap).toHaveBeenCalledTimes(1); + expect(dispatchBitmap).toHaveBeenCalledWith('mask1', expect.objectContaining({ imageName: 'mask-img' }), { + x: 7, + y: 8, + }); + expect(dispatch).not.toHaveBeenCalled(); + store.dispose(); + }); + + it('retains dirty pixels when the intended layer rejects the persisted bitmap', async () => { + const surface = createTestStubRasterBackend().createSurface(10, 10); + const dispatchBitmap = vi.fn(() => false); + const store = createBitmapStore({ + debounceMs: 1500, + dispatch: vi.fn(), + dispatchBitmap, + encodeSurface: () => Promise.resolve(new Blob(['pixels'], { type: 'image/png' })), + getLayerSource: () => ({ bitmap: null, type: 'paint' }), + getLayerSurface: () => ({ offset: { x: 0, y: 0 }, surface }), + hashBlob: (blob) => blob.text(), + uploadImage: () => Promise.resolve({ height: 10, imageName: 'rejected-img', width: 10 }), + }); + + store.markLayerDirty(LAYER); + await expect(store.flushPendingUploads()).rejects.toThrow('Canvas pixel persistence failed'); + await expect(store.flushPendingUploads()).rejects.toThrow('Canvas pixel persistence failed'); + + expect(dispatchBitmap).toHaveBeenCalledTimes(2); + store.dispose(); + }); + + it('retains dirty pixels when a legacy dispatcher returns no acceptance signal', async () => { + const surface = createTestStubRasterBackend().createSurface(10, 10); + const dispatchBitmap = vi.fn(() => undefined) as unknown as ( + layerId: string, + bitmap: CanvasImageRef, + offset: { x: number; y: number } + ) => boolean; + const store = createBitmapStore({ + debounceMs: 1500, + dispatch: vi.fn(() => true), + dispatchBitmap, + encodeSurface: () => Promise.resolve(new Blob(['pixels'], { type: 'image/png' })), + getLayerSource: () => ({ bitmap: null, type: 'paint' }), + getLayerSurface: () => ({ offset: { x: 0, y: 0 }, surface }), + hashBlob: (blob) => blob.text(), + uploadImage: () => Promise.resolve({ height: 10, imageName: 'missing-acceptance-img', width: 10 }), + }); + + store.markLayerDirty(LAYER); + await expect(store.flushPendingUploads()).rejects.toThrow('Canvas pixel persistence failed'); + await expect(store.flushPendingUploads()).rejects.toThrow('Canvas pixel persistence failed'); + + expect(dispatchBitmap).toHaveBeenCalledTimes(2); + store.dispose(); + }); + + it.each(['dispatch', 'dispatchBitmap'] as const)( + 'retains dirty pixels when %s throws before the intended bitmap lands', + async (dispatchSeam) => { + const surface = createTestStubRasterBackend().createSurface(10, 10); + let shouldThrow = true; + const throwingDispatch = vi.fn(() => { + if (shouldThrow) { + throw new Error('subscriber failed before commit'); + } + return true; + }); + const onError = vi.fn(); + const store = createBitmapStore({ + debounceMs: 1500, + dispatch: dispatchSeam === 'dispatch' ? throwingDispatch : vi.fn(() => true), + dispatchBitmap: dispatchSeam === 'dispatchBitmap' ? throwingDispatch : undefined, + encodeSurface: () => Promise.resolve(new Blob(['pixels'], { type: 'image/png' })), + getLayerSource: () => ({ bitmap: null, type: 'paint' }), + getLayerSurface: () => ({ offset: { x: 0, y: 0 }, surface }), + hashBlob: (blob) => blob.text(), + onError, + uploadImage: () => Promise.resolve({ height: 10, imageName: 'retry-after-throw.png', width: 10 }), + }); + + store.markLayerDirty(LAYER); + await expect(store.flushPendingUploads()).rejects.toThrow('Canvas pixel persistence failed'); + + expect(throwingDispatch).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledOnce(); + expect( + store.isSelfEcho(LAYER, { + bitmap: { height: 10, imageName: 'retry-after-throw.png', width: 10 }, + type: 'paint', + }) + ).toBe(false); + + shouldThrow = false; + await store.flushPendingUploads(); + + expect(throwingDispatch).toHaveBeenCalledTimes(2); + store.dispose(); + } + ); + + it('accepts a thrown dispatch when authoritative state proves the intended bitmap landed', async () => { + const surface = createTestStubRasterBackend().createSurface(10, 10); + let source: CanvasLayerSourceContract | null = { bitmap: null, type: 'paint' }; + const dispatchBitmap = vi.fn((_layerId: string, bitmap: CanvasImageRef, offset: { x: number; y: number }) => { + source = { bitmap, offset, type: 'paint' }; + throw new Error('subscriber failed after commit'); + }); + const onError = vi.fn(); + const store = createBitmapStore({ + debounceMs: 1500, + dispatch: vi.fn(() => true), + dispatchBitmap, + encodeSurface: () => Promise.resolve(new Blob(['pixels'], { type: 'image/png' })), + getLayerSource: () => source, + getLayerSurface: () => ({ offset: { x: 7, y: 8 }, surface }), + hashBlob: (blob) => blob.text(), + onError, + uploadImage: () => Promise.resolve({ height: 10, imageName: 'landed-before-throw.png', width: 10 }), + }); + + store.markLayerDirty(LAYER); + await expect(store.flushPendingUploads()).resolves.toBeUndefined(); + await expect(store.flushPendingUploads()).resolves.toBeUndefined(); + + expect(dispatchBitmap).toHaveBeenCalledOnce(); + expect(onError).not.toHaveBeenCalled(); + expect(store.isSelfEcho(LAYER, source)).toBe(true); + store.dispose(); + }); + + it('drops dirty pixels when rejection confirms that the intended layer was deleted', async () => { + const surface = createTestStubRasterBackend().createSurface(10, 10); + let source: CanvasLayerSourceContract | null = { bitmap: null, type: 'paint' }; + const dispatchBitmap = vi.fn(() => { + source = null; + return false; + }); + const store = createBitmapStore({ + debounceMs: 1500, + dispatch: vi.fn(), + dispatchBitmap, + encodeSurface: () => Promise.resolve(new Blob(['pixels'], { type: 'image/png' })), + getLayerSource: () => source, + getLayerSurface: () => ({ offset: { x: 0, y: 0 }, surface }), + hashBlob: (blob) => blob.text(), + uploadImage: () => Promise.resolve({ height: 10, imageName: 'orphan-img', width: 10 }), + }); + + store.markLayerDirty(LAYER); + await store.flushPendingUploads(); + await store.flushPendingUploads(); + + expect(dispatchBitmap).toHaveBeenCalledTimes(1); + store.dispose(); + }); + + it('uploads a new image when the pixels change', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + h.setEncoded('pixels-B'); + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + expect(h.uploadImage).toHaveBeenCalledTimes(2); + expect(h.dispatch).toHaveBeenCalledTimes(2); + h.store.dispose(); + }); + + it('undo re-flush reuses the prior image and does not re-upload (history convergence)', async () => { + const h = createHarness(); + + // Paint state A → upload img-0. + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + // Paint state B → upload img-1. + h.setEncoded('pixels-B'); + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + expect(h.uploadImage).toHaveBeenCalledTimes(2); + + // Undo restores state A's pixels in the cache; the engine re-marks the layer + // dirty. The re-flush re-hashes to img-0's content and reuses it — NO upload. + h.setEncoded('pixels-A'); + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + expect(h.uploadImage).toHaveBeenCalledTimes(2); + // The contract converges back to img-0 (the previously uploaded state-A image). + const lastDispatch = h.dispatch.mock.calls.at(-1)?.[0]; + expect(lastDispatch).toMatchObject({ + source: { bitmap: { imageName: 'img-0' }, type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + h.store.dispose(); + }); + + it('swaps on success: dispatch fires only after the upload resolves', async () => { + const deferred = createDeferred(); + const uploadImage = vi.fn(() => deferred.promise); + const h = createHarness({ uploadImage }); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + + // Upload is in flight; the contract keeps its old ref (no dispatch yet). + expect(uploadImage).toHaveBeenCalledTimes(1); + expect(h.dispatch).not.toHaveBeenCalled(); + + deferred.resolve({ height: 10, imageName: 'img-x', width: 10 }); + await h.store.flushPendingUploads(); + + expect(h.dispatch).toHaveBeenCalledTimes(1); + expect(h.dispatch.mock.calls[0][0]).toMatchObject({ + id: LAYER, + source: { bitmap: { imageName: 'img-x' }, type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + h.store.dispose(); + }); + + it('on upload failure: no dispatch, layer stays dirty, then recovers on a later success', async () => { + let shouldFail = true; + const uploadImage = vi.fn(() => { + if (shouldFail) { + return Promise.reject(new Error('upload failed')); + } + return Promise.resolve({ height: 10, imageName: 'img-ok', width: 10 }); + }); + const onError = vi.fn(); + const surface = createTestStubRasterBackend().createSurface(10, 10); + const dispatch = vi.fn((_action: CanvasProjectMutation) => true); + const store = createBitmapStore({ + debounceMs: 1500, + dispatch, + encodeSurface: () => Promise.resolve(new Blob(['pixels'], { type: 'image/png' })), + getLayerSource: () => PAINT_SOURCE, + getLayerSurface: () => ({ offset: { x: 0, y: 0 }, surface }), + hashBlob: (blob) => blob.text(), + maxUploadAttempts: 2, + onError, + retryDelaysMs: [1], + sleep: () => Promise.resolve(), + uploadImage, + }); + + store.markLayerDirty(LAYER); + await expect(store.flushPendingUploads()).rejects.toThrow('Canvas pixel persistence failed'); + + // Retried up to the attempt cap, then gave up without dispatching. + expect(uploadImage).toHaveBeenCalledTimes(2); + expect(dispatch).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + + // The layer is still dirty; a subsequent flush succeeds and dispatches. + shouldFail = false; + store.markLayerDirty(LAYER); + await store.flushPendingUploads(); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0]).toMatchObject({ source: { bitmap: { imageName: 'img-ok' } } }); + store.dispose(); + }); + + it('flushPendingUploads is a barrier: it cancels the debounce and resolves only after uploads settle', async () => { + const deferred = createDeferred(); + const uploadImage = vi.fn(() => deferred.promise); + const h = createHarness({ uploadImage }); + + h.store.markLayerDirty(LAYER); + // Do NOT advance to the debounce window; the barrier must flush immediately. + const barrier = h.store.flushPendingUploads(); + + let settled = false; + void barrier.then(() => { + settled = true; + }); + + // Encode/hash have run and the upload is in flight, but it hasn't resolved. + // Drain the encode→hash microtask chain (several awaits) without resolving. + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + } + expect(uploadImage).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + + deferred.resolve({ height: 10, imageName: 'img-b', width: 10 }); + await barrier; + + expect(settled).toBe(true); + expect(h.dispatch).toHaveBeenCalledTimes(1); + h.store.dispose(); + }); + + it('suspends debounced dirty work and resumes it only after release', async () => { + const h = createHarness(); + h.store.markLayerDirty(LAYER); + const release = h.store.suspendLayer(LAYER); + + await vi.advanceTimersByTimeAsync(3000); + expect(h.uploadImage).not.toHaveBeenCalled(); + expect(h.dispatch).not.toHaveBeenCalled(); + + release(); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + expect(h.uploadImage).toHaveBeenCalledOnce(); + expect(h.dispatch).toHaveBeenCalledOnce(); + h.store.dispose(); + }); + + it('records dirty work marked during suspension without scheduling until release', async () => { + const h = createHarness(); + const release = h.store.suspendLayer(LAYER); + h.store.markLayerDirty(LAYER); + + await vi.advanceTimersByTimeAsync(3000); + expect(h.encodeSurface).not.toHaveBeenCalled(); + + release(); + await h.store.flushPendingUploads(); + + expect(h.encodeSurface).toHaveBeenCalledOnce(); + expect(h.dispatch).toHaveBeenCalledOnce(); + h.store.dispose(); + }); + + it('invalidates an in-flight result and keeps a barrier pending until suspension releases', async () => { + const uploads = [createDeferred(), createDeferred()]; + let uploadIndex = 0; + const uploadImage = vi.fn(() => uploads[uploadIndex++]!.promise); + const h = createHarness({ uploadImage }); + h.store.markLayerDirty(LAYER); + const barrier = h.store.flushPendingUploads(); + await drainUntil(() => uploadImage.mock.calls.length === 1); + + const release = h.store.suspendLayer(LAYER); + let settled = false; + void barrier.then(() => { + settled = true; + }); + uploads[0]!.resolve({ height: 10, imageName: 'obsolete', width: 10 }); + await drainUntil(() => settled); + + expect(settled).toBe(false); + expect(h.dispatch).not.toHaveBeenCalled(); + expect(uploadImage).toHaveBeenCalledOnce(); + + release(); + await drainUntil(() => uploadImage.mock.calls.length === 2); + uploads[1]!.resolve({ height: 10, imageName: 'fresh', width: 10 }); + await barrier; + + expect(h.dispatch).toHaveBeenCalledOnce(); + expect(h.dispatch.mock.calls[0]![0]).toMatchObject({ source: { bitmap: { imageName: 'fresh' } } }); + h.store.dispose(); + }); + + it('supports nested suspension leases and idempotent release', async () => { + const h = createHarness(); + h.store.markLayerDirty(LAYER); + const releaseOuter = h.store.suspendLayer(LAYER); + const releaseInner = h.store.suspendLayer(LAYER); + + releaseOuter(); + releaseOuter(); + await vi.advanceTimersByTimeAsync(3000); + expect(h.uploadImage).not.toHaveBeenCalled(); + + releaseInner(); + await h.store.flushPendingUploads(); + expect(h.uploadImage).toHaveBeenCalledOnce(); + h.store.dispose(); + }); + + it('ignores a stale suspension release after reset reuses the same layer id', async () => { + const h = createHarness(); + const releaseOldDocument = h.store.suspendLayer(LAYER); + + h.store.reset(); + + const releaseNewDocument = h.store.suspendLayer(LAYER); + h.store.markLayerDirty(LAYER); + const barrier = h.store.flushPendingUploads(); + let settled = false; + void barrier.then(() => { + settled = true; + }); + + releaseOldDocument(); + await vi.advanceTimersByTimeAsync(3000); + await Promise.resolve(); + + expect(settled).toBe(false); + expect(h.encodeSurface).not.toHaveBeenCalled(); + + releaseNewDocument(); + await barrier; + + expect(h.encodeSurface).toHaveBeenCalledOnce(); + expect(h.dispatch).toHaveBeenCalledOnce(); + h.store.dispose(); + }); + + it.each(['reset', 'dispose'] as const)('%s settles barriers waiting on suspended dirty work', async (ending) => { + const h = createHarness(); + h.store.markLayerDirty(LAYER); + h.store.suspendLayer(LAYER); + const barrier = h.store.flushPendingUploads(); + let settled = false; + void barrier.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + h.store[ending](); + await barrier; + + expect(settled).toBe(true); + expect(h.dispatch).not.toHaveBeenCalled(); + if (ending === 'reset') { + h.store.dispose(); + } + }); + + it('a stroke landing while the barrier awaits an in-flight upload is not dropped: the barrier waits for the follow-up flush', async () => { + const deferreds = [createDeferred(), createDeferred()]; + let call = 0; + const uploadImage = vi.fn(() => deferreds[call++].promise); + const h = createHarness({ uploadImage }); + + h.store.markLayerDirty(LAYER); + const barrier = h.store.flushPendingUploads(); + let settled = false; + void barrier.then(() => { + settled = true; + }); + + // Drain the encode→hash microtask chain: the first (stale) upload is in flight. + await drainUntil(() => uploadImage.mock.calls.length >= 1); + expect(uploadImage).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + + // A fresh stroke lands mid-flight: new pixels re-dirty the layer while the + // stale upload is still pending. + h.setEncoded('pixels-B'); + h.store.markLayerDirty(LAYER); + + // The stale upload resolves. The barrier must NOT settle yet: the layer + // was re-dirtied by a newer stroke during the await, so it owes a follow-up + // flush of the newer pixels before the "latest painted pixels" guarantee holds. + deferreds[0].resolve({ height: 10, imageName: 'img-old', width: 10 }); + await drainUntil(() => uploadImage.mock.calls.length >= 2); + expect(uploadImage).toHaveBeenCalledTimes(2); + expect(settled).toBe(false); + + deferreds[1].resolve({ height: 10, imageName: 'img-new', width: 10 }); + await barrier; + + expect(settled).toBe(true); + expect(h.dispatch).toHaveBeenCalledTimes(2); + expect(h.dispatch.mock.calls[1][0]).toMatchObject({ + id: LAYER, + source: { bitmap: { imageName: 'img-new' }, type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + h.store.dispose(); + }); + + it('a persistently failing layer does not spin the barrier: it rejects after one bounded attempt and stays dirty', async () => { + const uploadImage = vi.fn(() => Promise.reject(new Error('upload failed'))); + const onError = vi.fn(); + const surface = createTestStubRasterBackend().createSurface(10, 10); + const dispatch = vi.fn((_action: CanvasProjectMutation) => true); + const store = createBitmapStore({ + debounceMs: 1500, + dispatch, + encodeSurface: () => Promise.resolve(new Blob(['pixels'], { type: 'image/png' })), + getLayerSource: () => PAINT_SOURCE, + getLayerSurface: () => ({ offset: { x: 0, y: 0 }, surface }), + hashBlob: (blob) => blob.text(), + maxUploadAttempts: 2, + onError, + retryDelaysMs: [1], + sleep: () => Promise.resolve(), + uploadImage, + }); + + store.markLayerDirty(LAYER); + await expect(store.flushPendingUploads()).rejects.toThrow('Canvas pixel persistence failed'); + + // The internal retry cap (maxUploadAttempts) bounds a single flush's own + // attempts. The barrier must not loop back and re-attempt a layer whose + // flush already FAILED this call, so the total stays at that cap instead + // of growing with extra barrier iterations (no spin). + expect(uploadImage).toHaveBeenCalledTimes(2); + expect(dispatch).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + + // The layer is still dirty (deferred, not dropped): a later debounce retries it. + await vi.advanceTimersByTimeAsync(1500); + expect(uploadImage.mock.calls.length).toBeGreaterThan(2); + + store.dispose(); + }); + + it('isSelfEcho recognizes the exact ref it just applied and rejects a different one', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + const applied = h.dispatch.mock.calls[0][0] as Extract; + const appliedSource = applied.source; + + // The dispatch's own round-trip is a self-echo → the engine skips re-raster. + expect(h.store.isSelfEcho(LAYER, appliedSource)).toBe(true); + + // A different bitmap (undo/import) is NOT an echo → must re-rasterize. + const otherPaint: CanvasLayerSourceContract = { + bitmap: { height: 10, imageName: 'other', width: 10 }, + type: 'paint', + }; + expect(h.store.isSelfEcho(LAYER, otherPaint)).toBe(false); + + // A different layer, and non-paint / null sources, are never echoes. + expect(h.store.isSelfEcho('layer-2', appliedSource)).toBe(false); + expect(h.store.isSelfEcho(LAYER, { image: { height: 1, imageName: 'i', width: 1 }, type: 'image' })).toBe(false); + expect(h.store.isSelfEcho(LAYER, null)).toBe(false); + h.store.dispose(); + }); + + it('reset() clears the self-echo guard so a reused layer id is not suppressed', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + expect(h.dispatch).toHaveBeenCalledTimes(1); + expect(h.uploadImage).toHaveBeenCalledTimes(1); + + const applied = h.dispatch.mock.calls[0][0] as Extract; + expect(h.store.isSelfEcho(LAYER, applied.source)).toBe(true); + + // A wholesale document replacement drops the outgoing document's self-echo + // bookkeeping (a reused layer id must not inherit it). + h.store.reset(); + expect(h.store.isSelfEcho(LAYER, applied.source)).toBe(false); + + // Re-persisting identical pixels now dispatches again: the content-hash + // dedupe reuses the already-uploaded image (no new upload), but the stale + // self-echo no longer suppresses the dispatch, so the contract converges. + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + expect(h.dispatch).toHaveBeenCalledTimes(2); + expect(h.uploadImage).toHaveBeenCalledTimes(1); + + h.store.dispose(); + }); + + it('reset() cancels a pending debounced flush for the outgoing document', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + h.store.reset(); + await vi.advanceTimersByTimeAsync(3000); + + expect(h.uploadImage).not.toHaveBeenCalled(); + expect(h.dispatch).not.toHaveBeenCalled(); + + h.store.dispose(); + }); + + it('discardLayer cancels pending and in-flight persistence for one cleared layer', async () => { + const deferred = createDeferred(); + const uploadImage = vi.fn(() => deferred.promise); + const h = createHarness({ uploadImage }); + + h.store.markLayerDirty(LAYER); + const barrier = h.store.flushPendingUploads(); + await drainUntil(() => uploadImage.mock.calls.length >= 1); + + h.store.discardLayer(LAYER); + deferred.resolve({ height: 10, imageName: 'stale-mask', width: 10 }); + await barrier; + await vi.advanceTimersByTimeAsync(3000); + + expect(h.dispatch).not.toHaveBeenCalled(); + h.store.dispose(); + }); + + it.each(['discard', 'reset'] as const)( + 'does not resurrect dirty persistence when an in-flight upload rejects after %s', + async (cancellation) => { + const deferred = createDeferred(); + const uploadImage = vi.fn(() => deferred.promise); + const onError = vi.fn(); + const h = createHarness({ maxUploadAttempts: 1, onError, uploadImage }); + h.store.markLayerDirty(LAYER); + const barrier = h.store.flushPendingUploads(); + await drainUntil(() => uploadImage.mock.calls.length >= 1); + + if (cancellation === 'discard') { + h.store.discardLayer(LAYER); + } else { + h.store.reset(); + } + deferred.reject(new Error('obsolete upload failed')); + await barrier; + await vi.advanceTimersByTimeAsync(3000); + + expect(uploadImage).toHaveBeenCalledOnce(); + expect(h.dispatch).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + await h.store.flushPendingUploads(); + expect(uploadImage).toHaveBeenCalledOnce(); + h.store.dispose(); + } + ); + + it.each(['discard', 'reset'] as const)( + 'does not resurrect dirty persistence when in-flight encoding rejects after %s', + async (cancellation) => { + const deferred = createDeferred(); + const encodeSurface = vi.fn(() => deferred.promise); + const onError = vi.fn(); + const h = createHarness({ encodeSurface, onError }); + h.store.markLayerDirty(LAYER); + const barrier = h.store.flushPendingUploads(); + await drainUntil(() => encodeSurface.mock.calls.length >= 1); + + if (cancellation === 'discard') { + h.store.discardLayer(LAYER); + } else { + h.store.reset(); + } + deferred.reject(new Error('obsolete encode failed')); + await barrier; + await vi.advanceTimersByTimeAsync(3000); + + expect(encodeSurface).toHaveBeenCalledOnce(); + expect(h.uploadImage).not.toHaveBeenCalled(); + expect(h.dispatch).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + await h.store.flushPendingUploads(); + expect(encodeSurface).toHaveBeenCalledOnce(); + h.store.dispose(); + } + ); + + it.each(['discard', 'reset'] as const)( + 'does not hash or upload when in-flight encoding fulfills after %s', + async (cancellation) => { + const deferred = createDeferred(); + const encodeSurface = vi.fn(() => deferred.promise); + const hashBlob = vi.fn((blob: Blob) => blob.text()); + const h = createHarness({ encodeSurface, hashBlob }); + h.store.markLayerDirty(LAYER); + const barrier = h.store.flushPendingUploads(); + await drainUntil(() => encodeSurface.mock.calls.length >= 1); + + if (cancellation === 'discard') { + h.store.discardLayer(LAYER); + } else { + h.store.reset(); + } + deferred.resolve(new Blob(['obsolete pixels'], { type: 'image/png' })); + await barrier; + + expect(hashBlob).not.toHaveBeenCalled(); + expect(h.uploadImage).not.toHaveBeenCalled(); + expect(h.dispatch).not.toHaveBeenCalled(); + h.store.dispose(); + } + ); + + it.each(['discard', 'reset'] as const)( + 'does not upload when in-flight hashing fulfills after %s', + async (cancellation) => { + const deferred = createDeferred(); + const hashBlob = vi.fn(() => deferred.promise); + const h = createHarness({ hashBlob }); + h.store.markLayerDirty(LAYER); + const barrier = h.store.flushPendingUploads(); + await drainUntil(() => hashBlob.mock.calls.length >= 1); + + if (cancellation === 'discard') { + h.store.discardLayer(LAYER); + } else { + h.store.reset(); + } + deferred.resolve('obsolete-hash'); + await barrier; + + expect(h.uploadImage).not.toHaveBeenCalled(); + expect(h.dispatch).not.toHaveBeenCalled(); + h.store.dispose(); + } + ); + + it.each(['discard', 'reset'] as const)( + 'stops obsolete upload retries when %s lands during retry backoff', + async (cancellation) => { + const backoff = createDeferred(); + const sleep = vi.fn(() => backoff.promise); + const uploadImage = vi.fn(() => Promise.reject(new Error('retry me'))); + const onError = vi.fn(); + const h = createHarness({ maxUploadAttempts: 3, onError, sleep, uploadImage }); + h.store.markLayerDirty(LAYER); + const barrier = h.store.flushPendingUploads(); + await drainUntil(() => sleep.mock.calls.length >= 1); + expect(uploadImage).toHaveBeenCalledOnce(); + + if (cancellation === 'discard') { + h.store.discardLayer(LAYER); + } else { + h.store.reset(); + } + backoff.resolve(); + await barrier; + + expect(uploadImage).toHaveBeenCalledOnce(); + expect(onError).not.toHaveBeenCalled(); + expect(h.dispatch).not.toHaveBeenCalled(); + h.store.dispose(); + } + ); + + it('allows fresh same-id persistence after repeated idle discards', async () => { + const h = createHarness(); + for (let i = 0; i < 1_000; i += 1) { + h.store.discardLayer(`removed-${i}`); + } + + h.store.discardLayer(LAYER); + h.store.markLayerDirty(LAYER); + await h.store.flushPendingUploads(); + + expect(h.uploadImage).toHaveBeenCalledOnce(); + expect(h.dispatch).toHaveBeenCalledOnce(); + h.store.dispose(); + }); + + it('preserves a fresh same-id generation while obsolete encoding settles', async () => { + const obsoleteEncode = createDeferred(); + let encodeCall = 0; + const encodeSurface = vi.fn(() => { + encodeCall += 1; + return encodeCall === 1 + ? obsoleteEncode.promise + : Promise.resolve(new Blob(['fresh pixels'], { type: 'image/png' })); + }); + const hashBlob = vi.fn((blob: Blob) => blob.text()); + const h = createHarness({ encodeSurface, hashBlob }); + h.store.markLayerDirty(LAYER); + const barrier = h.store.flushPendingUploads(); + await drainUntil(() => encodeSurface.mock.calls.length === 1); + + h.store.discardLayer(LAYER); + h.store.markLayerDirty(LAYER); + obsoleteEncode.resolve(new Blob(['obsolete pixels'], { type: 'image/png' })); + await barrier; + + expect(encodeSurface).toHaveBeenCalledTimes(2); + expect(hashBlob).toHaveBeenCalledOnce(); + expect(await hashBlob.mock.calls[0]![0].text()).toBe('fresh pixels'); + expect(h.uploadImage).toHaveBeenCalledOnce(); + expect(h.dispatch).toHaveBeenCalledOnce(); + h.store.dispose(); + }); + + it.each(['discard', 'reset'] as const)( + 'lets an error observer %s failed persistence without a later dirty resurrection', + async (cancellation) => { + const uploadImage = vi.fn(() => Promise.reject(new Error('upload failed'))); + let store: ReturnType | null = null; + const onError = vi.fn(() => { + if (cancellation === 'discard') { + store?.discardLayer(LAYER); + } else { + store?.reset(); + } + }); + const h = createHarness({ maxUploadAttempts: 1, onError, uploadImage }); + store = h.store; + + h.store.markLayerDirty(LAYER); + await h.store.flushPendingUploads(); + await vi.advanceTimersByTimeAsync(3000); + + expect(onError).toHaveBeenCalledOnce(); + expect(uploadImage).toHaveBeenCalledOnce(); + expect(h.dispatch).not.toHaveBeenCalled(); + await h.store.flushPendingUploads(); + expect(uploadImage).toHaveBeenCalledOnce(); + h.store.dispose(); + } + ); + + it('does not flush after dispose', async () => { + const h = createHarness(); + h.store.markLayerDirty(LAYER); + h.store.dispose(); + await vi.advanceTimersByTimeAsync(3000); + expect(h.uploadImage).not.toHaveBeenCalled(); + }); + + describe('source-type guard (rasterize → undo convergence)', () => { + it('drops the dirty entry without dispatching when the debounce timer fires after the layer left `paint`', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + // The layer converted back to a parametric source (e.g. rasterize → undo) + // before the debounce window elapsed — the cache surface still resolves + // (a source swap doesn't clear it), so only this guard prevents a stale + // paint dispatch. + h.setSource({ + fill: '#ff0000', + height: 40, + kind: 'rect', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 60, + }); + + await vi.advanceTimersByTimeAsync(1500); + + expect(h.uploadImage).not.toHaveBeenCalled(); + expect(h.dispatch).not.toHaveBeenCalled(); + h.store.dispose(); + }); + + it('drops the dirty entry without dispatching when `flushPendingUploads` is awaited after the layer left `paint`', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + h.setSource({ angle: 0, kind: 'linear', stops: [{ color: '#000', offset: 0 }], type: 'gradient' }); + + // Do NOT advance timers: exercise the barrier path (e.g. pressing Invoke + // right after the undo), not just the debounce path. + await h.store.flushPendingUploads(); + + expect(h.uploadImage).not.toHaveBeenCalled(); + expect(h.dispatch).not.toHaveBeenCalled(); + h.store.dispose(); + }); + + it('drops the dirty entry without dispatching when the layer no longer exists', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + h.setSource(null); + + await h.store.flushPendingUploads(); + + expect(h.uploadImage).not.toHaveBeenCalled(); + expect(h.dispatch).not.toHaveBeenCalled(); + h.store.dispose(); + }); + + it('closes the race where the source leaves `paint` WHILE an upload is already in flight', async () => { + const deferred = createDeferred(); + const uploadImage = vi.fn(() => deferred.promise); + const h = createHarness({ uploadImage }); + + h.store.markLayerDirty(LAYER); + const barrier = h.store.flushPendingUploads(); + + // Encode/hash/upload have started (passing the entry-time guard while the + // source was still `paint`); the upload is now in flight. + await drainUntil(() => uploadImage.mock.calls.length >= 1); + expect(uploadImage).toHaveBeenCalledTimes(1); + + // The source changes away from `paint` DURING the in-flight upload — + // later than the entry-time check, so only the pre-dispatch recheck + // catches it. + h.setSource({ fill: '#000', height: 10, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 10 }); + + deferred.resolve({ height: 10, imageName: 'img-x', width: 10 }); + await barrier; + + expect(h.dispatch).not.toHaveBeenCalled(); + h.store.dispose(); + }); + + it('still flushes normally when the layer stays a paint layer (no regression)', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + expect(h.uploadImage).toHaveBeenCalledTimes(1); + expect(h.dispatch).toHaveBeenCalledTimes(1); + expect(h.dispatch.mock.calls[0][0]).toMatchObject({ + id: LAYER, + source: { type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + h.store.dispose(); + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/bitmapStore.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/bitmapStore.ts new file mode 100644 index 00000000000..3b779d8e925 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/bitmapStore.ts @@ -0,0 +1,687 @@ +/** + * The bitmap store: paint persistence via content-hashed server images. + * + * Painting bakes strokes straight into a layer's raster cache surface (see + * `strokeSession`/`paintTool`), but nothing persists — a reload would lose the + * strokes. This store closes that loop: on each committed stroke it marks the + * layer dirty, and after a short idle window it encodes the layer's full cache + * surface to a PNG, content-hashes it (SHA-256), dedupes against already + * uploaded bitmaps, uploads new ones, and — only once the upload succeeds — + * dispatches `updateCanvasLayerSource` so the reducer-owned document points its + * paint layer at the persisted image name. The reducer stays pixel-free: only a + * `CanvasImageRef` (name + dims + hash) ever crosses the boundary. + * + * Key invariants: + * - **Swap-on-success**: the contract keeps its previous ref until the upload + * resolves. A failed upload never dispatches; the layer stays dirty and the + * old ref stays valid, so a reload still shows the last persisted pixels. + * - **Debounce per layer** (~1.5 s idle): a new stroke resets the timer, so a + * burst of strokes uploads once. + * - **Content-hash dedupe**: identical pixels reuse the previously uploaded + * image name and skip the upload entirely. This is what makes undo cheap + * (restoring old pixels re-hashes to the already-uploaded image). + * - **Self-echo guard**: the dispatch round-trips back through the document + * mirror as a source change for the layer. {@link BitmapStore.isSelfEcho} + * lets the engine skip re-rasterizing/invalidating the cache for the exact + * bitmap ref this store just applied (the pixels already match). + * - **Source-type guard**: a layer's cache surface survives a source-type + * change (e.g. rasterize's paint bake, then an undo back to shape/gradient) + * — only the document's source pointer changes, not the cache. So every + * flush re-checks `getLayerSource` (at entry AND again right before the + * dispatch, since encode/hash/upload all await) and drops the pending work + * without dispatching if the layer is no longer `paint` (or no longer + * exists). Otherwise a stale debounced flush would silently convert a + * parametric layer back to `paint` with wrong-extent pixels. + * - **Redundant-dispatch skip is ground-truth, not memory**: right before + * dispatching, a flush skips if the DOCUMENT's current bitmap ref (via + * `getLayerSource`) already equals the resolved image name — not if + * `lastApplied` (this store's memory of what it last dispatched) does. A + * round trip through a non-`paint` source and back (rasterize → undo → + * redo) leaves `lastApplied` pointing at a name the document no longer + * references (the redo lands on `{ bitmap: null }`); comparing against that + * stale memory would suppress the re-dispatch forever. Comparing against + * the document itself self-heals regardless of how it drifted. + * + * Every side-effecting dependency (encode, upload, hash, dispatch, timers) is + * injectable, so this runs in node tests with fakes. Zero React. + */ + +import type { CanvasImageUploadResult } from '@workbench/canvas-engine/document/imageUpload'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasImageRef, CanvasLayerSourceContract } from '@workbench/types'; + +/** Default idle window before a dirty layer is flushed. */ +export const DEFAULT_DEBOUNCE_MS = 1500; +/** Default upload attempts per flush (initial try + retries). */ +export const DEFAULT_MAX_UPLOAD_ATTEMPTS = 3; +/** Default backoff delays (ms) between upload retries. */ +export const DEFAULT_RETRY_DELAYS_MS = [250, 1000] as const; +/** Default cap on the hash→image dedupe map. */ +export const DEFAULT_DEDUPE_CAP = 64; + +export class BitmapPersistenceError extends Error { + readonly layerIds: readonly string[]; + + constructor(layerIds: readonly string[]) { + super(`Canvas pixel persistence failed for ${layerIds.length} layer${layerIds.length === 1 ? '' : 's'}.`); + this.name = 'BitmapPersistenceError'; + this.layerIds = [...layerIds]; + } +} + +/** Injectable timer seam (defaults to the global timers). */ +export interface BitmapStoreTimers { + setTimeout(handler: () => void, ms: number): number; + clearTimeout(handle: number): void; +} + +/** Dependencies for {@link createBitmapStore}. */ +export interface BitmapStoreDeps { + /** + * Returns a layer's live cache surface (its painted pixels) plus the layer-local + * `offset` its top-left pixel sits at (its content rect origin), or `null` when + * the cache is gone/empty. The surface is CONTENT-SIZED, so the encoded PNG + * covers only the painted region and the dispatched paint source carries the + * offset (loading rasterizes at it). Read atomically here so the encoded pixels + * and the offset always agree. + */ + getLayerSurface(layerId: string): { surface: RasterSurface; offset: { x: number; y: number } } | null; + /** + * Returns a layer's CURRENT document source, or `null` if the layer no + * longer exists. Used to guard a flush against a source-type change that + * happened AFTER the dirty mark was recorded — e.g. rasterize (paint) → + * undo (back to shape/gradient) — where the layer's cache surface still + * resolves (it isn't cleared by the source swap) but persisting it would + * dispatch stale paint pixels over a now-parametric layer. + */ + getLayerSource(layerId: string): CanvasLayerSourceContract | null; + /** + * Reads a layer source from reducer-owned project state, bypassing subscriber- + * refreshed mirrors. Used only to verify whether a dispatch that threw after + * reducer commit nevertheless landed exactly as intended. + */ + getAuthoritativeLayerSource?(layerId: string): CanvasLayerSourceContract | null; + /** Encodes a surface to an image `Blob` (PNG). Usually `backend.encodeSurface`. */ + encodeSurface(surface: RasterSurface): Promise; + /** Uploads a bitmap blob, resolving to its server image name and dimensions. */ + uploadImage(blob: Blob): Promise; + /** Dispatches to the reducer (the single swap-on-success `updateCanvasLayerSource`). */ + dispatch(action: CanvasProjectMutation): boolean; + /** + * Applies the persisted bitmap ref + offset to the layer's document contract, + * as the single swap-on-success dispatch. Lets the engine pick the right + * action per layer type — `updateCanvasLayerSource` (paint source) for raster/ + * control layers, `updateCanvasLayerConfig` (mask) for inpaint/regional masks — + * while the store stays type-agnostic. Absent ⇒ the default paint-source + * dispatch (used by the store's own tests, which only exercise paint layers). + */ + dispatchBitmap?(layerId: string, bitmap: CanvasImageRef, offset: { x: number; y: number }): boolean; + /** Content-hashes a blob (defaults to SHA-256 hex via `crypto.subtle`). */ + hashBlob?(blob: Blob): Promise; + /** Idle debounce window in ms (default {@link DEFAULT_DEBOUNCE_MS}). */ + debounceMs?: number; + /** Upload attempts per flush (default {@link DEFAULT_MAX_UPLOAD_ATTEMPTS}). */ + maxUploadAttempts?: number; + /** Backoff delays between retries (default {@link DEFAULT_RETRY_DELAYS_MS}). */ + retryDelaysMs?: readonly number[]; + /** Cap on the dedupe map (default {@link DEFAULT_DEDUPE_CAP}). */ + dedupeCap?: number; + /** Injectable timers (default: global). */ + timers?: BitmapStoreTimers; + /** Injectable delay used for retry backoff (default: `timers.setTimeout`). */ + sleep?(ms: number): Promise; + /** Reports a persistent flush/upload failure. Omitted callbacks leave the failure unreported. */ + onError?(error: unknown, layerId: string): void; +} + +/** The imperative bitmap-store handle. */ +export interface BitmapStore { + /** Marks a layer dirty and (re)arms its debounce timer. Called on each committed stroke. */ + markLayerDirty(layerId: string): void; + /** + * Temporarily prevents persistence from reading or dispatching `layerId` while + * preserving dirty work. Returns an idempotent release; leases may be nested. + */ + suspendLayer(layerId: string): () => void; + /** Cancels pending persistence and invalidates an in-flight result for one layer. */ + discardLayer(layerId: string): void; + /** Flushes every dirty layer immediately and resolves once all in-flight uploads settle. */ + flushPendingUploads(): Promise; + /** + * True when `source` is exactly the paint bitmap ref this store most recently + * applied to `layerId` — i.e. the engine is seeing its own dispatch round-trip + * and must NOT re-rasterize/invalidate the cache (the pixels already match). + * A different bitmap (undo/import) returns `false` and re-rasterizes as usual. + */ + isSelfEcho(layerId: string, source: CanvasLayerSourceContract | null): boolean; + /** + * Drops the persistence bookkeeping that describes the OUTGOING document, for + * use on a wholesale document replacement. Clears the `lastApplied` self-echo + * map (a reused layer id in the new document could otherwise have a legit + * persistence dispatch suppressed forever) and any pending dirty/debounced + * work for the old document. The content-hash dedupe cache is intentionally + * kept — it is a pure content-addressed mapping (identical PNG bytes → the + * same immutable uploaded image) and so is never stale across documents. + */ + reset(): void; + /** Cancels all timers; in-flight uploads are left to settle (no dispatch after dispose). */ + dispose(): void; +} + +const defaultTimers: BitmapStoreTimers = { + clearTimeout: (handle) => globalThis.clearTimeout(handle), + setTimeout: (handler, ms) => globalThis.setTimeout(handler, ms), +}; + +/** SHA-256 hex of a blob's bytes, via the Web Crypto API (Node ≥ 20 exposes `crypto.subtle`). */ +const defaultHashBlob = async (blob: Blob): Promise => { + const buffer = await blob.arrayBuffer(); + const digest = await crypto.subtle.digest('SHA-256', buffer); + const bytes = new Uint8Array(digest); + let hex = ''; + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, '0'); + } + return hex; +}; + +/** Creates a bitmap store wired to the given seams. */ +export const createBitmapStore = (deps: BitmapStoreDeps): BitmapStore => { + const debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const maxAttempts = Math.max(1, deps.maxUploadAttempts ?? DEFAULT_MAX_UPLOAD_ATTEMPTS); + const retryDelays = deps.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS; + const dedupeCap = Math.max(1, deps.dedupeCap ?? DEFAULT_DEDUPE_CAP); + const timers = deps.timers ?? defaultTimers; + const hashBlob = deps.hashBlob ?? defaultHashBlob; + const sleep = + deps.sleep ?? + ((ms: number): Promise => + new Promise((resolve) => { + timers.setTimeout(resolve, ms); + })); + const reportError = (error: unknown, layerId: string): void => deps.onError?.(error, layerId); + + /** Layers awaiting a flush (either debounced or re-dirtied during a flush). */ + const dirty = new Set(); + /** + * Why a layer is currently in `dirty`: `'stroke'` means a new paint stroke + * (re)marked it — worth retrying inside a barrier call; `'failure'` means + * its last flush attempt exhausted upload retries — the barrier must not + * retry it again within the same {@link flushPendingUploads} call (anti-spin). + * A stroke landing after a failure flips this back to `'stroke'`. + */ + const dirtyReason = new Map(); + /** Active debounce timers, keyed by layer id. */ + const debounceTimers = new Map(); + /** The in-flight flush op per layer (at most one), used by the barrier and to serialize. */ + const inFlight = new Map>(); + /** Content-hash → uploaded image, an LRU-ish dedupe cache (bounded). */ + const hashToImage = new Map(); + /** Layer id → the image name most recently dispatched by this store (self-echo guard). */ + const lastApplied = new Map(); + /** + * Per-layer generation used only while invalidated work is still in flight. + * Idle ids are removed so ordinary layer deletion cannot accumulate permanent + * tombstones for the lifetime of the engine. + */ + const layerGenerations = new Map(); + /** Active nested persistence-suspension generation per layer. */ + const suspensions = new Map(); + /** Barriers waiting for a suspended dirty layer to resume or be reset/disposed. */ + const suspensionWaiters = new Set<() => void>(); + let disposed = false; + + const isSuspended = (layerId: string): boolean => (suspensions.get(layerId)?.count ?? 0) > 0; + const notifySuspensionWaiters = (): void => { + const waiters = [...suspensionWaiters]; + suspensionWaiters.clear(); + for (const resolve of waiters) { + resolve(); + } + }; + const waitForSuspensionChange = (): Promise => + new Promise((resolve) => { + suspensionWaiters.add(resolve); + }); + + const clearTimer = (layerId: string): void => { + const handle = debounceTimers.get(layerId); + if (handle !== undefined) { + timers.clearTimeout(handle); + debounceTimers.delete(layerId); + } + }; + + const scheduleFlush = (layerId: string): void => { + clearTimer(layerId); + const handle = timers.setTimeout(() => { + debounceTimers.delete(layerId); + void runFlush(layerId); + }, debounceMs); + debounceTimers.set(layerId, handle); + }; + + const rememberDedupe = (hash: string, result: CanvasImageUploadResult): void => { + hashToImage.delete(hash); + hashToImage.set(hash, result); + while (hashToImage.size > dedupeCap) { + const oldest = hashToImage.keys().next().value; + if (oldest === undefined) { + break; + } + hashToImage.delete(oldest); + } + }; + + const touchDedupe = (hash: string, result: CanvasImageUploadResult): void => { + // Move to the most-recently-used end. + hashToImage.delete(hash); + hashToImage.set(hash, result); + }; + + const uploadWithRetry = async ( + blob: Blob, + isCurrentGeneration: () => boolean + ): Promise => { + let lastError: unknown; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + if (!isCurrentGeneration()) { + return null; + } + if (attempt > 0) { + const delay = retryDelays[Math.min(attempt - 1, retryDelays.length - 1)] ?? 0; + if (delay > 0) { + await sleep(delay); + if (!isCurrentGeneration()) { + return null; + } + } + } + if (!isCurrentGeneration()) { + return null; + } + try { + const result = await deps.uploadImage(blob); + if (!isCurrentGeneration()) { + return null; + } + return result; + } catch (error) { + if (!isCurrentGeneration()) { + return null; + } + lastError = error; + } + } + throw lastError ?? new Error('Canvas image upload failed'); + }; + + /** Encodes → hashes → dedupes/uploads → swaps the layer's ref, once. */ + const flushLayer = async (layerId: string): Promise => { + const generationAtEntry = layerGenerations.get(layerId) ?? 0; + const isCurrentGeneration = (): boolean => !disposed && (layerGenerations.get(layerId) ?? 0) === generationAtEntry; + const requeueFailure = (error: unknown): void => { + if (!isCurrentGeneration()) { + return; + } + // Requeue before notifying. An injected error observer may deliberately + // discard/reset this generation; its cancellation must be the final word, + // not followed by a stale dirty.add(). Observer exceptions are ancillary. + dirty.add(layerId); + dirtyReason.set(layerId, 'failure'); + try { + reportError(error, layerId); + } catch { + // Keep the bounded retry state intact when an observer itself fails. + } + }; + const placed = deps.getLayerSurface(layerId); + if (!placed) { + // Layer or its cache is gone (or empty); nothing to persist. + dirty.delete(layerId); + clearTimer(layerId); + return; + } + // Capture the surface AND its offset together at encode time so they agree: + // encode reads these pixels, and the dispatch below carries this offset. A + // growth during the async encode window re-marks the layer (its stroke marks + // it dirty), so a follow-up flush re-converges with the current rect + offset. + const { offset, surface } = placed; + // Source-type guard (see `getLayerSource` doc): the dirty mark may predate + // a conversion away from `paint` (rasterize → undo is the motivating case, + // but any convert-back qualifies). The cache surface still resolves above + // — a source swap doesn't clear it — so without this check we'd encode and + // dispatch a `paint` source over a layer that is no longer paint at all. + // Drop the pending flush entirely: nothing about this dirty mark is still + // valid, and a future genuine paint stroke will re-mark it if the layer + // ever becomes a paint layer again. + const sourceAtEntry = deps.getLayerSource(layerId); + if (!sourceAtEntry || sourceAtEntry.type !== 'paint') { + dirty.delete(layerId); + clearTimer(layerId); + return; + } + // Consume the dirty flag up front; a failure re-adds it below. A stroke that + // lands mid-flush re-marks the layer, so the finally handler re-schedules. + dirty.delete(layerId); + clearTimer(layerId); + + let hash: string; + let blob: Blob; + try { + blob = await deps.encodeSurface(surface); + if (!isCurrentGeneration()) { + return; + } + hash = await hashBlob(blob); + if (!isCurrentGeneration()) { + return; + } + } catch (error) { + requeueFailure(error); + return; + } + + let result = hashToImage.get(hash); + if (result) { + // Dedupe hit: identical pixels already uploaded — reuse the name, no upload. + touchDedupe(hash, result); + } else { + try { + const uploaded = await uploadWithRetry(blob, isCurrentGeneration); + if (!uploaded) { + return; + } + result = uploaded; + } catch (error) { + // Swap-on-success: never dispatch on failure. The old ref stays valid + // and the layer stays dirty for a later retry. + requeueFailure(error); + return; + } + rememberDedupe(hash, result); + } + + if (!isCurrentGeneration()) { + return; + } + // Re-check the source right before dispatching: `encodeSurface`/`hashBlob`/ + // `uploadImage` above all awaited, so a source-type change (rasterize → + // undo) landing DURING that window would slip past the entry-time + // `sourceAtEntry` check otherwise. This is the final gate before the + // side-effecting dispatch. + const sourceNow = deps.getLayerSource(layerId); + if (!sourceNow || sourceNow.type !== 'paint') { + return; + } + // The DOCUMENT already points at this exact image (e.g. a re-flush of + // identical pixels that hash-deduped to an already-applied ref): skip a + // redundant dispatch and its self-echo round-trip. + // + // This is checked against `sourceNow` (the document's CURRENT bitmap ref), + // not `lastApplied` (this store's memory of what it last dispatched) — the + // two can diverge. `lastApplied` is never cleared when a layer's source is + // converted away from `paint` and back (e.g. `rasterizeLayer` → undo → + // redo: the document round-trips through a parametric source and back to + // `paint`, landing on `{ bitmap: null }`, while `lastApplied` still holds + // the previously-uploaded name from before the undo). Comparing against + // `lastApplied` in that case would suppress the dispatch forever, leaving + // the document permanently pointed at `bitmap: null` even though the + // dedupe correctly resolved the identical baked pixels back to the prior + // image. Comparing against the document's actual current ref is the + // ground truth for "is this dispatch a no-op" and self-heals regardless + // of how the document's source got out of sync with this store's memory. + // + // The OFFSET must match too: a pure-translation transform (drag + Apply) + // bakes byte-identical pixels → same hash → dedupe resolves to the + // already-referenced image, but the paint offset moved. Comparing only + // `imageName` would skip the dispatch that persists the new offset, so the + // translation would be silently lost on reload (and every re-flush would + // re-skip it forever). Compare the captured `offset` (which travels with + // this dispatch) against the document's current offset — absent offsets are + // the legacy origin `{ x: 0, y: 0 }`. + const currentOffset = sourceNow.bitmap ? (sourceNow.offset ?? { x: 0, y: 0 }) : null; + if ( + sourceNow.bitmap?.imageName === result.imageName && + currentOffset !== null && + currentOffset.x === offset.x && + currentOffset.y === offset.y + ) { + return; + } + + const bitmap: CanvasImageRef = { + contentHash: hash, + height: result.height, + imageName: result.imageName, + width: result.width, + }; + // Record BEFORE dispatching: `dispatch` may notify the mirror synchronously, + // so `isSelfEcho` must already see the applied name when the engine reacts. + lastApplied.set(layerId, result.imageName); + let accepted: boolean; + try { + accepted = deps.dispatchBitmap + ? deps.dispatchBitmap(layerId, bitmap, { x: offset.x, y: offset.y }) + : deps.dispatch({ + id: layerId, + source: { bitmap, offset: { x: offset.x, y: offset.y }, type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + } catch (error) { + const authoritativeSource = (deps.getAuthoritativeLayerSource ?? deps.getLayerSource)(layerId); + const authoritativeOffset = + authoritativeSource?.type === 'paint' && authoritativeSource.bitmap + ? (authoritativeSource.offset ?? { x: 0, y: 0 }) + : null; + const didLand = + authoritativeSource?.type === 'paint' && + authoritativeSource.bitmap?.imageName === bitmap.imageName && + authoritativeSource.bitmap.width === bitmap.width && + authoritativeSource.bitmap.height === bitmap.height && + authoritativeSource.bitmap.contentHash === bitmap.contentHash && + authoritativeOffset?.x === offset.x && + authoritativeOffset.y === offset.y; + if (didLand) { + return; + } + lastApplied.delete(layerId); + if (authoritativeSource !== null) { + requeueFailure(error); + } + return; + } + if (accepted !== true) { + lastApplied.delete(layerId); + if (deps.getLayerSource(layerId) !== null) { + dirty.add(layerId); + dirtyReason.set(layerId, 'failure'); + } + } + }; + + /** Runs (or joins) a flush for a layer, serializing to one in-flight op per layer. */ + const runFlush = (layerId: string): Promise => { + const existing = inFlight.get(layerId); + if (existing) { + return existing; + } + if (isSuspended(layerId)) { + return Promise.resolve(); + } + const op = flushLayer(layerId).finally(() => { + inFlight.delete(layerId); + // Re-dirtied during the flush (new stroke) or a failure re-queued it. + if (dirty.has(layerId) && !disposed && !isSuspended(layerId)) { + scheduleFlush(layerId); + } else { + // Any invalidated operation for this id has now settled and there is no + // successor waiting to inherit its generation. Retire the tombstone. + layerGenerations.delete(layerId); + } + }); + inFlight.set(layerId, op); + return op; + }; + + const markLayerDirty = (layerId: string): void => { + if (disposed) { + return; + } + dirty.add(layerId); + dirtyReason.set(layerId, 'stroke'); + if (!isSuspended(layerId)) { + scheduleFlush(layerId); + } + }; + + const suspendLayer = (layerId: string): (() => void) => { + if (disposed) { + return () => undefined; + } + const currentSuspension = suspensions.get(layerId); + const count = currentSuspension?.count ?? 0; + const token = currentSuspension?.token ?? Symbol(layerId); + suspensions.set(layerId, { count: count + 1, token }); + if (count === 0) { + const hadPendingWork = dirty.has(layerId) || debounceTimers.has(layerId) || inFlight.has(layerId); + clearTimer(layerId); + if (inFlight.has(layerId)) { + layerGenerations.set(layerId, (layerGenerations.get(layerId) ?? 0) + 1); + } + if (hadPendingWork) { + dirty.add(layerId); + dirtyReason.set(layerId, 'stroke'); + } + } + + let released = false; + return () => { + if (released) { + return; + } + released = true; + const current = suspensions.get(layerId); + if (!current || current.token !== token) { + return; + } + if (current.count <= 1) { + suspensions.delete(layerId); + if (dirty.has(layerId) && !disposed) { + scheduleFlush(layerId); + } + } else { + suspensions.set(layerId, { count: current.count - 1, token }); + } + notifySuspensionWaiters(); + }; + }; + + const discardLayer = (layerId: string): void => { + if (inFlight.has(layerId)) { + // Keep an invalidating generation only while obsolete async work can + // still settle. Same-id work arriving before it settles inherits this + // generation and is scheduled after the old operation completes. + layerGenerations.set(layerId, (layerGenerations.get(layerId) ?? 0) + 1); + } else { + layerGenerations.delete(layerId); + } + dirty.delete(layerId); + dirtyReason.delete(layerId); + lastApplied.delete(layerId); + clearTimer(layerId); + }; + + /** Safety net against a genuine infinite loop; real barrier calls settle in a handful of rounds. */ + const MAX_BARRIER_ITERATIONS = 10_000; + + const flushPendingUploads = async (): Promise => { + // Immediately flush every currently-dirty layer (cancelling its debounce), + // then await the in-flight ops — looping so a layer re-dirtied by a NEW + // stroke that lands while its upload is in flight gets a follow-up flush + // before the barrier resolves (the "document points at the latest painted + // pixels" guarantee). A layer whose flush FAILED within this barrier call + // is not retried again this call — only a fresh stroke re-enters the loop + // for it — so a persistently failing upload still can't spin the barrier + // forever. + const failedThisBarrier = new Set(); + for (let iteration = 0; iteration < MAX_BARRIER_ITERATIONS; iteration += 1) { + const toFlush = Array.from(dirty).filter((layerId) => !failedThisBarrier.has(layerId) && !isSuspended(layerId)); + for (const layerId of toFlush) { + clearTimer(layerId); + void runFlush(layerId); + } + const ops = [...inFlight.values()]; + if (ops.length === 0) { + if (Array.from(dirty).some((layerId) => isSuspended(layerId))) { + await waitForSuspensionChange(); + continue; + } + const failedLayerIds = Array.from(failedThisBarrier).filter( + (layerId) => dirty.has(layerId) && dirtyReason.get(layerId) === 'failure' + ); + if (failedLayerIds.length > 0) { + throw new BitmapPersistenceError(failedLayerIds); + } + return; + } + await Promise.all(ops); + for (const layerId of toFlush) { + if (dirty.has(layerId) && dirtyReason.get(layerId) === 'failure') { + failedThisBarrier.add(layerId); + } + } + } + throw new Error('Canvas pixel persistence barrier exceeded its iteration limit.'); + }; + + const isSelfEcho = (layerId: string, source: CanvasLayerSourceContract | null): boolean => { + if (!source || source.type !== 'paint') { + return false; + } + const imageName = source.bitmap?.imageName; + return imageName !== undefined && lastApplied.get(layerId) === imageName; + }; + + const reset = (): void => { + for (const layerId of inFlight.keys()) { + layerGenerations.set(layerId, (layerGenerations.get(layerId) ?? 0) + 1); + } + for (const layerId of layerGenerations.keys()) { + if (!inFlight.has(layerId)) { + layerGenerations.delete(layerId); + } + } + // Cancel pending debounced flushes and drop dirty state for the OLD document. + for (const handle of debounceTimers.values()) { + timers.clearTimeout(handle); + } + debounceTimers.clear(); + dirty.clear(); + dirtyReason.clear(); + suspensions.clear(); + notifySuspensionWaiters(); + // The self-echo map is per-(old)document; a reused layer id in the new + // document must not inherit it. `hashToImage` is content-addressed and kept. + lastApplied.clear(); + }; + + const dispose = (): void => { + disposed = true; + for (const handle of debounceTimers.values()) { + timers.clearTimeout(handle); + } + debounceTimers.clear(); + dirty.clear(); + dirtyReason.clear(); + suspensions.clear(); + notifySuspensionWaiters(); + inFlight.clear(); + hashToImage.clear(); + lastApplied.clear(); + layerGenerations.clear(); + }; + + return { discardLayer, dispose, flushPendingUploads, isSelfEcho, markLayerDirty, reset, suspendLayer }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/documentMirror.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/documentMirror.test.ts new file mode 100644 index 00000000000..104b5bfa456 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/documentMirror.test.ts @@ -0,0 +1,379 @@ +import type { + CanvasDocumentContractV2, + CanvasInpaintMaskLayerContract, + CanvasLayerContract, + CanvasRasterLayerContractV2, + CanvasStagingAreaContractV2, + CanvasStateContractV2, +} from '@workbench/types'; + +import { describe, expect, it, vi } from 'vitest'; + +import type { DocumentMirrorCallbacks } from './documentMirror'; + +import { createDocumentMirror } from './documentMirror'; + +const rasterLayer = (id: string, overrides: Partial = {}): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height: 10, imageName: id, width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...overrides, +}); + +const makeDoc = ( + layers: CanvasLayerContract[], + overrides: Partial = {} +): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId: null, + version: 2, + width: 100, + ...overrides, +}); + +const makeStaging = (): CanvasStagingAreaContractV2 => ({ + areThumbnailsVisible: false, + autoSwitchMode: 'off', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, +}); + +const makeCanvas = (document: CanvasDocumentContractV2, documentRevision = 0): CanvasStateContractV2 => ({ + document, + documentRevision, + snapshots: [], + stagingArea: makeStaging(), + version: 2, +}); + +interface FakeProject { + id: string; + canvas: CanvasStateContractV2; +} + +const createFakeStore = (projects: FakeProject[]) => { + let state = { projects }; + const listeners = new Set<() => void>(); + return { + getState: () => state, + replaceStateSilently: (next: { projects: FakeProject[] }) => { + state = next; + }, + setState: (next: { projects: FakeProject[] }) => { + state = next; + for (const listener of listeners) { + listener(); + } + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +}; + +const spyCallbacks = () => ({ + onBboxChanged: vi.fn(), + onDocumentReplaced: vi.fn(), + onLayerOrderChanged: vi.fn(), + onLayersChanged: vi.fn(), + onStagingChanged: vi.fn(), +}); + +describe('createDocumentMirror', () => { + it('synchronously refreshes from authoritative state when normal notification was interrupted', () => { + const a = rasterLayer('a'); + const doc = makeDoc([a]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + const mirror = createDocumentMirror(store, 'p1', callbacks); + const updated: CanvasDocumentContractV2 = { ...doc, layers: [{ ...a, opacity: 0.5 }] }; + + store.replaceStateSilently({ projects: [{ canvas: { ...canvas, document: updated }, id: 'p1' }] }); + expect(mirror.getDocument()).toBe(doc); + + mirror.refresh(); + + expect(mirror.getDocument()).toBe(updated); + expect(callbacks.onLayersChanged).toHaveBeenCalledWith(['a'], []); + }); + + it('reports exactly the edited layer id when one layer changes by reference', () => { + const a = rasterLayer('a'); + const b = rasterLayer('b'); + const doc = makeDoc([a, b]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // Replace only layer `a` (new object), keep `b` identity. A prop-only edit + // (like the reducer's `updateCanvasLayer`) keeps the `source` reference, so + // the id is reported as changed but NOT source-changed. + const nextDoc: CanvasDocumentContractV2 = { ...doc, layers: [{ ...a, opacity: 0.5 }, b] }; + store.setState({ projects: [{ canvas: { ...canvas, document: nextDoc }, id: 'p1' }] }); + + expect(callbacks.onLayersChanged).toHaveBeenCalledTimes(1); + expect(callbacks.onLayersChanged).toHaveBeenCalledWith(['a'], []); + expect(callbacks.onDocumentReplaced).not.toHaveBeenCalled(); + expect(callbacks.onBboxChanged).not.toHaveBeenCalled(); + }); + + it('reports a prop-only edit as changed but not source-changed, and a source swap as both', () => { + const a = rasterLayer('a'); + const doc = makeDoc([a]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // Prop-only edit (opacity): spreading the prior layer preserves its `source` + // reference exactly as the reducer does, so the engine must NOT re-rasterize + // (which would clear an unflushed paint layer). + const opacityEdit: CanvasDocumentContractV2 = { ...doc, layers: [{ ...a, opacity: 0.5 }] }; + store.setState({ projects: [{ canvas: { ...canvas, document: opacityEdit }, id: 'p1' }] }); + expect(callbacks.onLayersChanged).toHaveBeenLastCalledWith(['a'], []); + + // Genuine source swap (new `source` object): reported as source-changed. + const swapped = store.getState().projects[0]!.canvas.document.layers[0] as CanvasRasterLayerContractV2; + const sourceSwap: CanvasDocumentContractV2 = { + ...doc, + layers: [{ ...swapped, source: { image: { height: 10, imageName: 'a-v2', width: 10 }, type: 'image' } }], + }; + store.setState({ projects: [{ canvas: { ...canvas, document: sourceSwap }, id: 'p1' }] }); + expect(callbacks.onLayersChanged).toHaveBeenLastCalledWith(['a'], ['a']); + }); + + it('for a mask: a fill-only change is NOT source-changed, a bitmap swap IS (protects unflushed strokes)', () => { + const mask: CanvasInpaintMaskLayerContract = { + blendMode: 'normal', + id: 'm', + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + name: 'm', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', + }; + const doc = makeDoc([mask]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // A fill-only change (new `mask` object, same `bitmap` ref): reported as + // changed but NOT source-changed — invalidating would clear unflushed strokes. + const fillEdit: CanvasDocumentContractV2 = { + ...doc, + layers: [{ ...mask, mask: { bitmap: null, fill: { color: '#00ff00', style: 'grid' } } }], + }; + store.setState({ projects: [{ canvas: { ...canvas, document: fillEdit }, id: 'p1' }] }); + expect(callbacks.onLayersChanged).toHaveBeenLastCalledWith(['m'], []); + + // A bitmap swap (persistence round-trip / undo): reported as source-changed. + const current = store.getState().projects[0]!.canvas.document.layers[0] as CanvasInpaintMaskLayerContract; + const bitmapSwap: CanvasDocumentContractV2 = { + ...doc, + layers: [{ ...current, mask: { ...current.mask, bitmap: { height: 20, imageName: 'mask-v1', width: 30 } } }], + }; + store.setState({ projects: [{ canvas: { ...canvas, document: bitmapSwap }, id: 'p1' }] }); + expect(callbacks.onLayersChanged).toHaveBeenLastCalledWith(['m'], ['m']); + }); + + it('reports added and removed layer ids', () => { + const a = rasterLayer('a'); + const doc = makeDoc([a]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + const b = rasterLayer('b'); + // Added layers are reported as source-changed (no prior cache to keep). + store.setState({ projects: [{ canvas: { ...canvas, document: { ...doc, layers: [b, a] } }, id: 'p1' }] }); + expect(callbacks.onLayersChanged).toHaveBeenLastCalledWith(['b'], ['b']); + + // A removal is a change but not a source change (the id has no incoming source). + store.setState({ projects: [{ canvas: { ...canvas, document: { ...doc, layers: [b] } }, id: 'p1' }] }); + expect(callbacks.onLayersChanged).toHaveBeenLastCalledWith(['a'], []); + }); + + it('fires onLayerOrderChanged exactly once on a pure reorder, with no layer ids reported', () => { + const a = rasterLayer('a'); + const b = rasterLayer('b'); + const doc = makeDoc([a, b]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // New array reference, same element references, swapped order. + store.setState({ projects: [{ canvas: { ...canvas, document: { ...doc, layers: [b, a] } }, id: 'p1' }] }); + + expect(callbacks.onLayerOrderChanged).toHaveBeenCalledTimes(1); + expect(callbacks.onLayersChanged).not.toHaveBeenCalled(); + expect(callbacks.onDocumentReplaced).not.toHaveBeenCalled(); + expect(callbacks.onBboxChanged).not.toHaveBeenCalled(); + }); + + it('does not fire onLayerOrderChanged when the layers array is replaced with an identical order', () => { + const a = rasterLayer('a'); + const b = rasterLayer('b'); + const doc = makeDoc([a, b]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // New array reference, same element references, same order: a true no-op churn. + store.setState({ projects: [{ canvas: { ...canvas, document: { ...doc, layers: [a, b] } }, id: 'p1' }] }); + + expect(callbacks.onLayerOrderChanged).not.toHaveBeenCalled(); + expect(callbacks.onLayersChanged).not.toHaveBeenCalled(); + }); + + it('fires onDocumentReplaced when dimensions change', () => { + const doc = makeDoc([rasterLayer('a')]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + store.setState({ + projects: [{ canvas: { ...canvas, document: { ...doc, width: 200 } }, id: 'p1' }], + }); + expect(callbacks.onDocumentReplaced).toHaveBeenCalledTimes(1); + expect(callbacks.onLayersChanged).not.toHaveBeenCalled(); + }); + + it('fires onDocumentReplaced when documentRevision changes, even with identical dims and layer ids', () => { + const doc = makeDoc([rasterLayer('a')]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // A same-dims snapshot restore: structuredClone reuses layer ids and keeps + // width/height/background, so only the revision bump signals the swap. + const restored = structuredClone(doc); + store.setState({ projects: [{ canvas: makeCanvas(restored, 1), id: 'p1' }] }); + + expect(callbacks.onDocumentReplaced).toHaveBeenCalledTimes(1); + expect(callbacks.onLayersChanged).not.toHaveBeenCalled(); + expect(callbacks.onLayerOrderChanged).not.toHaveBeenCalled(); + }); + + it('does not fire onDocumentReplaced for an ordinary layer edit at an unchanged revision', () => { + const a = rasterLayer('a'); + const doc = makeDoc([a]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + const nextDoc: CanvasDocumentContractV2 = { ...doc, layers: [{ ...a, opacity: 0.5 }] }; + store.setState({ projects: [{ canvas: { ...canvas, document: nextDoc }, id: 'p1' }] }); + + expect(callbacks.onDocumentReplaced).not.toHaveBeenCalled(); + expect(callbacks.onLayersChanged).toHaveBeenCalledWith(['a'], []); + }); + + it('fires onBboxChanged when only the bbox moves', () => { + const layers = [rasterLayer('a')]; + const doc = makeDoc(layers); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // Same layers array identity, new bbox. + store.setState({ + projects: [ + { canvas: { ...canvas, document: { ...doc, bbox: { height: 100, width: 100, x: 10, y: 10 } } }, id: 'p1' }, + ], + }); + expect(callbacks.onBboxChanged).toHaveBeenCalledTimes(1); + expect(callbacks.onLayersChanged).not.toHaveBeenCalled(); + expect(callbacks.onDocumentReplaced).not.toHaveBeenCalled(); + }); + + it('fires onStagingChanged when the staging area reference changes', () => { + const doc = makeDoc([rasterLayer('a')]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + store.setState({ projects: [{ canvas: { ...canvas, stagingArea: makeStaging() }, id: 'p1' }] }); + expect(callbacks.onStagingChanged).toHaveBeenCalledTimes(1); + expect(callbacks.onDocumentReplaced).not.toHaveBeenCalled(); + }); + + it('is silent when an unrelated project changes (identity short-circuit)', () => { + const docA = makeDoc([rasterLayer('a')]); + const canvasA = makeCanvas(docA); + const docB = makeDoc([rasterLayer('z')]); + const canvasB = makeCanvas(docB); + const store = createFakeStore([ + { canvas: canvasA, id: 'p1' }, + { canvas: canvasB, id: 'p2' }, + ]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // Mutate only p2; p1's canvas keeps its identity. + store.setState({ + projects: [ + { canvas: canvasA, id: 'p1' }, + { canvas: makeCanvas(makeDoc([rasterLayer('z', { opacity: 0.2 })])), id: 'p2' }, + ], + }); + expect(callbacks.onLayersChanged).not.toHaveBeenCalled(); + expect(callbacks.onDocumentReplaced).not.toHaveBeenCalled(); + expect(callbacks.onBboxChanged).not.toHaveBeenCalled(); + expect(callbacks.onStagingChanged).not.toHaveBeenCalled(); + }); + + it('reports null and stays no-op safe when the project is deleted', () => { + const doc = makeDoc([rasterLayer('a')]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + const mirror = createDocumentMirror(store, 'p1', callbacks); + + store.setState({ projects: [] }); + expect(callbacks.onDocumentReplaced).toHaveBeenCalledTimes(1); + expect(mirror.getDocument()).toBeNull(); + + // Further unrelated churn: no additional callbacks. + store.setState({ projects: [] }); + expect(callbacks.onDocumentReplaced).toHaveBeenCalledTimes(1); + }); + + it('stops observing after dispose', () => { + const doc = makeDoc([rasterLayer('a')]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + const mirror = createDocumentMirror(store, 'p1', callbacks); + + mirror.dispose(); + store.setState({ projects: [{ canvas: makeCanvas(makeDoc([rasterLayer('a', { opacity: 0.1 })])), id: 'p1' }] }); + expect(callbacks.onLayersChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/documentMirror.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/documentMirror.ts new file mode 100644 index 00000000000..238d369df34 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/documentMirror.ts @@ -0,0 +1,240 @@ +/** + * The document mirror: the one-way bridge from the reducer-owned canvas + * document into the engine. + * + * The reducer owns the document; the engine only reads it. External changes + * (layer edits, undo/redo, staging accept, project switch) arrive via a plain + * `store.subscribe`, and the mirror translates a new state into the narrowest + * possible set of callbacks by diffing against the last-seen references: + * + * - document object identical → nothing happened (fast path; this is what makes + * unrelated-project changes free, since the reducer preserves identity). + * - `documentRevision` changed → `onDocumentReplaced` (a full invalidate). This + * is bumped by the reducer on wholesale document swaps (snapshot restore, + * `replaceCanvasDocument`) that reuse the same dimensions and layer ids, which + * a reference/dimension diff alone cannot tell apart from an ordinary edit. + * - dimensions / background changed, or the document appeared/disappeared → + * `onDocumentReplaced` (a full invalidate). + * - otherwise diff the layer array element-wise by reference (id-keyed) → + * `onLayersChanged(changedIds, sourceChangedIds)`. The second argument is the + * subset of ids whose *rasterization source* reference changed (an image/paint + * swap, or a newly added layer) — as opposed to a prop/transform-only edit + * (opacity, blend, lock, visibility, rename, nudge) that replaces the layer + * object but keeps its `source`/`mask` reference. The engine invalidates a + * layer's raster cache ONLY for a source change: re-rasterizing on a prop edit + * is wasteful for image layers and destructive for an unflushed paint layer + * (a `bitmap: null` source rasterizes to a cleared surface, wiping cached + * strokes that have not been persisted yet). + * - layers array reference changed but every id kept the same element + * reference, just in a different order (a pure reorder) → + * `onLayerOrderChanged` (recomposite only; nothing to re-rasterize). + * - bbox value changed → `onBboxChanged`. + * - stagingArea reference changed → `onStagingChanged`. + * + * A missing/deleted project is a no-op safe: the mirror simply reports `null`. + * + * Zero React, zero import-time side effects. + */ + +import type { CanvasDocumentContractV2, CanvasStagingAreaContractV2, CanvasStateContractV2 } from '@workbench/types'; + +/** The minimal store shape the mirror depends on (a superset of `WorkbenchStore`). */ +export interface DocumentMirrorStore { + getCanvasState?(): CanvasStateContractV2 | null; + getState?(): { projects: readonly { id: string; canvas: CanvasStateContractV2 }[] }; + subscribe(listener: () => void): () => void; +} + +/** Callbacks fired when the mirrored document changes. */ +export interface DocumentMirrorCallbacks { + /** + * One or more layers were added, removed, or replaced (the `changed` ids). + * `sourceChanged` is the subset whose rasterization source (`source` for + * raster/control layers, `mask` for guidance/mask layers) reference changed, + * plus any newly added layer — i.e. the layers whose cached pixels are now + * stale. A prop/transform-only edit reports the id in `changed` but NOT in + * `sourceChanged`, so the engine keeps (rather than clears) its raster cache. + */ + onLayersChanged(changed: string[], sourceChanged: string[]): void; + /** + * The layers array was reordered (a new array reference, same element + * references, different sequence) — recomposite the document with the new + * z-order, but nothing needs re-rasterizing. + */ + onLayerOrderChanged(): void; + /** The document was replaced wholesale (dims/background change, appear/disappear) — full invalidate. */ + onDocumentReplaced(): void; + /** The generation bounding box changed. */ + onBboxChanged(): void; + /** The staging area changed. */ + onStagingChanged(): void; +} + +/** The imperative mirror handle. */ +export interface DocumentMirror { + /** The current mirrored document, or `null` if the project is gone. */ + getDocument(): CanvasDocumentContractV2 | null; + /** Synchronously reconciles from store state when ordinary notification was interrupted. */ + refresh(): void; + /** Removes the store subscription. */ + dispose(): void; +} + +type Bbox = CanvasDocumentContractV2['bbox']; + +const bboxEqual = (a: Bbox, b: Bbox): boolean => + a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + +/** + * The reference whose change requires re-rasterizing a layer's cache: the + * `source` for raster/control layers, the mask's `bitmap` for guidance/mask + * layers. The reducer preserves this reference across a prop/transform-only edit + * (it spreads `...layer`), so comparing it distinguishes a genuine source swap + * from an opacity/blend/lock/visibility/rename/nudge tweak. + * + * For masks the reference is the mask BITMAP, not the whole `mask` object: the + * cache holds only the alpha stencil, so a fill-only change (colour/style/noise/ + * denoise-limit) must NOT invalidate it. Invalidating on a fill tweak would + * re-rasterize from `mask.bitmap` — clearing unflushed brush strokes that live + * only in the cache — while the compositor already reads the fresh fill from the + * mirror at draw time. A genuine bitmap swap (persistence round-trip, undo, + * import) still changes this reference and re-rasterizes (guarded by self-echo). + */ +const rasterSourceRef = (layer: CanvasDocumentContractV2['layers'][number]): unknown => + layer.type === 'raster' || layer.type === 'control' ? layer.source : layer.mask.bitmap; + +/** + * Diffs two layer arrays by object identity, keyed by layer id. Returns the ids + * of layers that were added, removed, or replaced (`changed`), and the subset + * whose rasterization source reference changed or that were newly added + * (`sourceChanged`) — the layers whose cached pixels are now stale. + */ +const diffLayers = ( + prev: readonly CanvasDocumentContractV2['layers'][number][], + next: readonly CanvasDocumentContractV2['layers'][number][] +): { changed: string[]; sourceChanged: string[] } => { + const prevById = new Map(prev.map((layer) => [layer.id, layer])); + const nextById = new Map(next.map((layer) => [layer.id, layer])); + const changed = new Set(); + const sourceChanged = new Set(); + + for (const layer of next) { + const before = prevById.get(layer.id); + if (!before) { + // Added: no prior cache, so its source is new by definition. + changed.add(layer.id); + sourceChanged.add(layer.id); + } else if (before !== layer) { + changed.add(layer.id); + if (rasterSourceRef(before) !== rasterSourceRef(layer)) { + sourceChanged.add(layer.id); + } + } + } + for (const layer of prev) { + if (!nextById.has(layer.id)) { + // Removed: reported so the engine drops its cache. Not a source change. + changed.add(layer.id); + } + } + return { changed: [...changed], sourceChanged: [...sourceChanged] }; +}; + +/** + * True when two layer arrays hold the same ids in a different sequence. + * Only meaningful to call once `diffLayers` has already confirmed no id was + * added, removed, or replaced by reference. + */ +const layerOrderChanged = ( + prev: readonly CanvasDocumentContractV2['layers'][number][], + next: readonly CanvasDocumentContractV2['layers'][number][] +): boolean => { + if (prev.length !== next.length) { + return true; + } + for (let i = 0; i < prev.length; i++) { + if (prev[i]?.id !== next[i]?.id) { + return true; + } + } + return false; +}; + +/** + * Creates a document mirror bound to `projectId`. Subscribes immediately and + * seeds the last-seen references from the current state (so no spurious + * callback fires on creation). + */ +export const createDocumentMirror = ( + store: DocumentMirrorStore, + projectIdOrCallbacks: string | DocumentMirrorCallbacks, + maybeCallbacks?: DocumentMirrorCallbacks +): DocumentMirror => { + const projectId = typeof projectIdOrCallbacks === 'string' ? projectIdOrCallbacks : null; + const callbacks = typeof projectIdOrCallbacks === 'string' ? maybeCallbacks : projectIdOrCallbacks; + if (!callbacks) { + throw new Error('DocumentMirror callbacks are required.'); + } + const selectCanvas = (): CanvasStateContractV2 | null => + store.getCanvasState?.() ?? + (projectId === null + ? null + : (store.getState?.().projects.find((project) => project.id === projectId)?.canvas ?? null)); + + let lastDoc: CanvasDocumentContractV2 | null = selectCanvas()?.document ?? null; + let lastRevision: number = selectCanvas()?.documentRevision ?? 0; + let lastStaging: CanvasStagingAreaContractV2 | null = selectCanvas()?.stagingArea ?? null; + + const handleChange = (): void => { + const canvas = selectCanvas(); + const doc = canvas?.document ?? null; + const revision = canvas?.documentRevision ?? 0; + const staging = canvas?.stagingArea ?? null; + + if (doc !== lastDoc) { + const prevDoc = lastDoc; + const prevRevision = lastRevision; + lastDoc = doc; + lastRevision = revision; + + if (!prevDoc || !doc) { + // Document appeared or disappeared: treat as a full replacement. + callbacks.onDocumentReplaced(); + } else if ( + revision !== prevRevision || + prevDoc.width !== doc.width || + prevDoc.height !== doc.height || + prevDoc.background !== doc.background + ) { + // A wholesale swap (revision bump) or a dims/background change: the pixel + // history no longer describes the live document. + callbacks.onDocumentReplaced(); + } else { + if (prevDoc.layers !== doc.layers) { + const { changed, sourceChanged } = diffLayers(prevDoc.layers, doc.layers); + if (changed.length > 0) { + callbacks.onLayersChanged(changed, sourceChanged); + } else if (layerOrderChanged(prevDoc.layers, doc.layers)) { + callbacks.onLayerOrderChanged(); + } + } + if (!bboxEqual(prevDoc.bbox, doc.bbox)) { + callbacks.onBboxChanged(); + } + } + } + + if (staging !== lastStaging) { + lastStaging = staging; + callbacks.onStagingChanged(); + } + }; + + const unsubscribe = store.subscribe(handleChange); + + return { + dispose: unsubscribe, + getDocument: () => lastDoc, + refresh: handleChange, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/imageUpload.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/imageUpload.ts new file mode 100644 index 00000000000..fc043be4731 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/imageUpload.ts @@ -0,0 +1,6 @@ +/** Dependency-neutral result returned after persisting or staging canvas pixels. */ +export interface CanvasImageUploadResult { + imageName: string; + width: number; + height: number; +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/layerFactories.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/layerFactories.ts new file mode 100644 index 00000000000..92d9ee4a173 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/layerFactories.ts @@ -0,0 +1,106 @@ +import type { Rect } from '@workbench/canvas-engine/types'; +import type { + CanvasControlLayerContract, + CanvasImageRef, + CanvasInpaintMaskLayerContract, + CanvasMaskFillContract, + CanvasRegionalGuidanceLayerContract, +} from '@workbench/types'; + +import { CONTROL_ADAPTER_DEFAULTS } from '@workbench/controlAdapters'; + +export const DEFAULT_INPAINT_MASK_FILL = { color: '#e07575', style: 'diagonal' } as const; +export const REGIONAL_GUIDANCE_FILL_COLORS: readonly string[] = [ + '#799ddb', + '#83d683', + '#fae150', + '#dc9065', + '#e07575', + '#d58bca', + '#a178d6', +]; + +const nextNumberedName = (prefix: string, existingNames: readonly string[]): string => { + const expression = new RegExp(`^${prefix} (\\d+)$`); + const used = new Set( + existingNames + .map((name) => Number(expression.exec(name.trim())?.[1])) + .filter((number) => Number.isInteger(number) && number > 0) + ); + let number = 1; + while (used.has(number)) { + number += 1; + } + return `${prefix} ${String(number)}`; +}; + +export const nextInpaintMaskName = (names: readonly string[]): string => nextNumberedName('Inpaint Mask', names); +export const nextControlLayerName = (names: readonly string[]): string => nextNumberedName('Control Layer', names); +export const nextRegionalGuidanceName = (names: readonly string[]): string => + nextNumberedName('Regional Guidance', names); +export const nextRegionalGuidanceFillColor = (count: number): string => + REGIONAL_GUIDANCE_FILL_COLORS[(count + 1) % REGIONAL_GUIDANCE_FILL_COLORS.length] ?? + REGIONAL_GUIDANCE_FILL_COLORS[0]!; + +export interface CreateMaskLayerFromImageInput { + image: CanvasImageRef; + rect: Rect; + id: string; + name: string; + fill: CanvasMaskFillContract; +} + +export const createInpaintMaskFromImage = (input: CreateMaskLayerFromImageInput): CanvasInpaintMaskLayerContract => ({ + blendMode: 'normal', + id: input.id, + isEnabled: true, + isLocked: false, + mask: { + bitmap: { ...input.image }, + fill: { ...input.fill }, + offset: { x: input.rect.x, y: input.rect.y }, + }, + name: input.name, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +export const createRegionalGuidanceFromImage = ( + input: CreateMaskLayerFromImageInput +): CanvasRegionalGuidanceLayerContract => ({ + autoNegative: false, + blendMode: 'normal', + id: input.id, + isEnabled: true, + isLocked: false, + mask: { + bitmap: { ...input.image }, + fill: { ...input.fill }, + offset: { x: input.rect.x, y: input.rect.y }, + }, + name: input.name, + negativePrompt: null, + opacity: 0.5, + positivePrompt: null, + referenceImages: [], + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'regional_guidance', +}); + +export const createControlLayer = (name: string, id: string, base?: string | null): CanvasControlLayerContract => { + const adapter = base === 'z-image' ? CONTROL_ADAPTER_DEFAULTS.z_image_control : CONTROL_ADAPTER_DEFAULTS.controlnet; + return { + adapter: { ...adapter, beginEndStepPct: [...adapter.beginEndStepPct] }, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: true, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeDown.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeDown.test.ts new file mode 100644 index 00000000000..176a28ade32 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeDown.test.ts @@ -0,0 +1,53 @@ +import type { CanvasLayerBaseContract } from '@workbench/types'; + +import { fromTRS, multiply } from '@workbench/canvas-engine/math/mat2d'; +import { describe, expect, it } from 'vitest'; + +import { mergeDownMatrix } from './mergeDown'; + +type Transform = CanvasLayerBaseContract['transform']; + +const transform = (patch: Partial = {}): Transform => ({ + rotation: 0, + scaleX: 1, + scaleY: 1, + x: 0, + y: 0, + ...patch, +}); + +const toMat = (t: Transform) => fromTRS({ x: t.x, y: t.y }, t.rotation, t.scaleX, t.scaleY); + +const closeTo = (a: number, b: number) => Math.abs(a - b) < 1e-9; + +describe('mergeDownMatrix', () => { + it('is the identity when both transforms are identity', () => { + expect(mergeDownMatrix(transform(), transform())).toEqual({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + }); + + it('translates the upper cache into the below layer local space (offset below cancels)', () => { + // Below sits at (10, 20); the upper layer sits at the document origin. In + // below-local space the origin is therefore at (-10, -20). + const m = mergeDownMatrix(transform({ x: 10, y: 20 }), transform()); + expect(m).not.toBeNull(); + expect(m!.e).toBeCloseTo(-10); + expect(m!.f).toBeCloseTo(-20); + }); + + it('satisfies below · M = above, so merged pixels land at their original document position', () => { + const below = transform({ scaleX: 2, scaleY: 2, x: 5, y: 7 }); + const above = transform({ scaleX: 3, x: 40, y: 12 }); + const m = mergeDownMatrix(below, above); + expect(m).not.toBeNull(); + + const recomposed = multiply(toMat(below), m!); + const expected = toMat(above); + for (const key of ['a', 'b', 'c', 'd', 'e', 'f'] as const) { + expect(closeTo(recomposed[key], expected[key])).toBe(true); + } + }); + + it('returns null when the below transform is non-invertible (zero scale)', () => { + expect(mergeDownMatrix(transform({ scaleX: 0 }), transform())).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeDown.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeDown.ts new file mode 100644 index 00000000000..03d5f370a46 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeDown.ts @@ -0,0 +1,38 @@ +/** + * Pure transform math for merging one layer down into the layer below it. + * + * "Merge down" bakes the upper layer's pixels into the lower layer while the + * reducer keeps the lower layer's transform verbatim (see + * `mergeCanvasLayersDown` — it never touches pixels). So the engine must draw + * the upper layer's cache into the lower layer's *local* space: upper-local → + * document (via the upper transform) → lower-local (via the inverse of the + * lower transform). Composing those gives the matrix returned here, which the + * engine feeds to `ctx.setTransform` when blitting the upper cache onto the + * merged surface. When the merged (lower) layer is later composited with its + * unchanged transform, the upper content lands back at its original document + * position: `lowerTransform · (lowerTransform⁻¹ · upperTransform) = upperTransform`. + * + * Zero React, zero DOM, zero import-time side effects. + */ + +import type { Mat2d } from '@workbench/canvas-engine/types'; +import type { CanvasLayerBaseContract } from '@workbench/types'; + +import { fromTRS, invert, multiply } from '@workbench/canvas-engine/math/mat2d'; + +type LayerTransform = CanvasLayerBaseContract['transform']; + +const transformToMat = (t: LayerTransform): Mat2d => fromTRS({ x: t.x, y: t.y }, t.rotation, t.scaleX, t.scaleY); + +/** + * The matrix that maps the upper layer's local (cache) space into the lower + * layer's local space: `inverse(lower) · upper`. Returns `null` when the lower + * transform is singular (zero scale) and cannot be inverted. + */ +export const mergeDownMatrix = (lowerTransform: LayerTransform, upperTransform: LayerTransform): Mat2d | null => { + const lowerInverse = invert(transformToMat(lowerTransform)); + if (!lowerInverse) { + return null; + } + return multiply(lowerInverse, transformToMat(upperTransform)); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.test.ts new file mode 100644 index 00000000000..1e8ac0ea47f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.test.ts @@ -0,0 +1,80 @@ +import type { + CanvasInpaintMaskLayerContract, + CanvasLayerContract, + CanvasRasterLayerContractV2, +} from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import { canMergeVisibleRasters, getMergeVisibleRasterLayers } from './mergeVisible'; + +const raster = (id: string, overrides: Partial = {}): CanvasRasterLayerContractV2 => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...overrides, +}); + +const mask = (id: string): CanvasInpaintMaskLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + name: id, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +const gradientRaster = (id: string): CanvasLayerContract => + raster(id, { + source: { + angle: 0, + height: 10, + kind: 'linear', + stops: [ + { color: '#000', offset: 0 }, + { color: '#fff', offset: 1 }, + ], + type: 'gradient', + width: 10, + }, + }); + +describe('getMergeVisibleRasterLayers', () => { + const hasContent = (id: string): boolean => id !== 'empty'; + + it('returns every visible raster with content in stack order', () => { + const layers = [ + raster('top'), + mask('mask'), + raster('hidden', { isEnabled: false }), + raster('locked', { isLocked: true }), + gradientRaster('gradient'), + raster('empty'), + raster('bottom'), + ]; + + expect(getMergeVisibleRasterLayers(layers, hasContent).map((layer) => layer.id)).toEqual([ + 'top', + 'locked', + 'gradient', + 'bottom', + ]); + expect(canMergeVisibleRasters(layers, hasContent)).toBe(true); + }); + + it('requires at least two visible raster layers with content', () => { + expect(canMergeVisibleRasters([raster('one')], hasContent)).toBe(false); + expect(canMergeVisibleRasters([raster('one'), raster('hidden', { isEnabled: false })], hasContent)).toBe(false); + expect(canMergeVisibleRasters([raster('one'), raster('empty')], hasContent)).toBe(false); + expect(canMergeVisibleRasters([], hasContent)).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.ts new file mode 100644 index 00000000000..5510635be35 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.ts @@ -0,0 +1,24 @@ +/** + * Pure contributor selection for the raster group's "merge visible" action. + * + * Legacy merge-visible composites every visible raster entity with content into + * a new raster layer. Source kind and lock state do not affect participation: + * locks prevent editing a source, not reading its rendered pixels. + */ + +import type { CanvasLayerContract } from '@workbench/types'; + +export type HasMergeVisibleContent = (layerId: string) => boolean; + +/** Returns eligible contributors in document order (top-most first). */ +export const getMergeVisibleRasterLayers = ( + layers: readonly CanvasLayerContract[], + hasContent: HasMergeVisibleContent +): CanvasLayerContract[] => + layers.filter((layer) => layer.type === 'raster' && layer.isEnabled && hasContent(layer.id)); + +/** Whether the raster group's merge-visible action has at least two contributors. */ +export const canMergeVisibleRasters = ( + layers: readonly CanvasLayerContract[], + hasContent: HasMergeVisibleContent +): boolean => getMergeVisibleRasterLayers(layers, hasContent).length >= 2; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/sources.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/sources.test.ts new file mode 100644 index 00000000000..466fbb62711 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/sources.test.ts @@ -0,0 +1,129 @@ +import type { CanvasDocumentContractV2, CanvasLayerContract, CanvasLayerSourceContract } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import { + getSourceBounds, + getSourceContentRect, + getSourcePixelSize, + isMaskLayer, + isRenderableLayer, + maskAsPaintSource, + renderableSourceOf, +} from './sources'; + +const doc = { height: 200, width: 300 } as CanvasDocumentContractV2; + +const inpaintMask = ( + bitmap: { imageName: string; width: number; height: number } | null, + offset?: { x: number; y: number } +): CanvasLayerContract => + ({ + blendMode: 'normal', + id: 'm1', + isEnabled: true, + isLocked: false, + mask: { bitmap, fill: { color: '#e07575', style: 'diagonal' }, ...(offset ? { offset } : {}) }, + name: 'M', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', + }) as CanvasLayerContract; + +describe('mask layers as paint-like sources', () => { + it('isMaskLayer / renderableSourceOf treat a mask as an alpha paint source', () => { + const layer = inpaintMask({ height: 30, imageName: 'mask', width: 40 }, { x: 5, y: 6 }); + expect(isMaskLayer(layer)).toBe(true); + expect(maskAsPaintSource(layer)).toEqual({ + bitmap: { height: 30, imageName: 'mask', width: 40 }, + offset: { x: 5, y: 6 }, + type: 'paint', + }); + expect(renderableSourceOf(layer)).toEqual(maskAsPaintSource(layer)); + }); + + it('an enabled mask is renderable; content rect is the bitmap dims at its offset', () => { + const layer = inpaintMask({ height: 30, imageName: 'mask', width: 40 }, { x: 5, y: 6 }); + expect(isRenderableLayer(layer)).toBe(true); + expect(getSourceContentRect(layer, doc)).toEqual({ height: 30, width: 40, x: 5, y: 6 }); + }); + + it('an empty (bitmap-less) mask is renderable but has an empty content rect', () => { + const layer = inpaintMask(null); + expect(isRenderableLayer(layer)).toBe(true); + expect(getSourceContentRect(layer, doc)).toEqual({ height: 0, width: 0, x: 0, y: 0 }); + }); + + it('a disabled mask is not renderable', () => { + const layer = inpaintMask({ height: 30, imageName: 'mask', width: 40 }); + (layer as { isEnabled: boolean }).isEnabled = false; + expect(isRenderableLayer(layer)).toBe(false); + }); +}); + +const rasterLayer = (source: CanvasLayerSourceContract, transformOver = {}): CanvasLayerContract => + ({ + blendMode: 'normal', + id: 'l1', + isEnabled: true, + isLocked: false, + name: 'L', + opacity: 1, + source, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0, ...transformOver }, + type: 'raster', + }) as CanvasLayerContract; + +const shape: CanvasLayerSourceContract = { + fill: '#000', + height: 40, + kind: 'rect', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 60, +}; + +const gradient: CanvasLayerSourceContract = { + angle: 0, + kind: 'linear', + stops: [{ color: '#000', offset: 0 }], + type: 'gradient', +}; + +describe('isRenderableLayer — parametric sources', () => { + it('renders rect/ellipse shapes and gradients', () => { + expect(isRenderableLayer(rasterLayer(shape))).toBe(true); + expect(isRenderableLayer(rasterLayer({ ...shape, kind: 'ellipse' }))).toBe(true); + expect(isRenderableLayer(rasterLayer(gradient))).toBe(true); + }); + + it('does not render a deferred polygon shape', () => { + expect(isRenderableLayer(rasterLayer({ ...shape, kind: 'polygon' }))).toBe(false); + }); + + it('does not render a disabled layer', () => { + expect(isRenderableLayer({ ...rasterLayer(shape), isEnabled: false })).toBe(false); + }); +}); + +describe('getSourcePixelSize — parametric sources', () => { + it('sizes a shape to its own extent', () => { + expect(getSourcePixelSize(rasterLayer(shape), doc)).toEqual({ height: 40, width: 60 }); + }); + + it('sizes a gradient to the document', () => { + expect(getSourcePixelSize(rasterLayer(gradient), doc)).toEqual({ height: 200, width: 300 }); + }); +}); + +describe('getSourceBounds — parametric sources', () => { + it('scales+offsets a shape by its transform', () => { + const bounds = getSourceBounds(rasterLayer(shape, { scaleX: 2, scaleY: 3, x: 10, y: 20 }), doc); + expect(bounds).toEqual({ height: 120, width: 120, x: 10, y: 20 }); + }); + + it('returns the whole document for a gradient', () => { + expect(getSourceBounds(rasterLayer(gradient), doc)).toEqual({ height: 200, width: 300, x: 0, y: 0 }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/sources.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/sources.ts new file mode 100644 index 00000000000..b77fdd1d966 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/sources.ts @@ -0,0 +1,208 @@ +/** + * Small pure helpers over canvas layers and their sources, shared by the engine + * (cache sizing, culling, fit-to-content) and the rasterizers. + * + * `getSourceBounds` returns a layer's axis-aligned bounds in **document space** + * (used for culling / fit-to-content), while `getSourcePixelSize` returns the + * **native** (unscaled) pixel dimensions of the layer's raster cache surface — + * the compositor applies the layer transform when drawing, so caches hold + * unscaled pixels. `isRenderableLayer` reports whether a layer can be + * rasterized today (enabled, with an image/paint source). + * + * Zero React, zero import-time side effects. + */ + +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerContract, CanvasLayerSourceContract } from '@workbench/types'; + +import { fromTRS } from '@workbench/canvas-engine/math/mat2d'; +import { transformBounds } from '@workbench/canvas-engine/math/rect'; +import { estimateTextExtent } from '@workbench/canvas-engine/render/rasterizers/textRasterizer'; + +/** A layer type that carries a rasterizable `source` (raster or control). */ +type SourceLayer = Extract; + +/** A mask-bearing layer (inpaint mask / regional guidance); its `mask` holds an alpha bitmap. */ +type MaskLayer = Extract; + +/** True when a layer carries a `source` field (raster / control layers). */ +const hasSource = (layer: CanvasLayerContract): layer is SourceLayer => + layer.type === 'raster' || layer.type === 'control'; + +/** True when a layer carries a `mask` (inpaint mask / regional guidance). */ +export const isMaskLayer = (layer: CanvasLayerContract): layer is MaskLayer => + layer.type === 'inpaint_mask' || layer.type === 'regional_guidance'; + +/** The number of strict composite groups (see {@link layerGroupRank}). */ +export const LAYER_GROUP_COUNT = 4; + +/** + * The strict composite-group rank of a layer, ordered bottom→top: raster (0) < + * control (1) < regional guidance (2) < inpaint mask (3). Mirrors legacy + * `arrangeEntities` / `CanvasEntityRendererModule` and the layers panel's grouped + * sections, so a layer's global insertion index never lets it draw — or be + * hit-tested — out of group order (a raster created above a control layer still + * composites, and is grabbed, below it). The compositor draws groups in this + * order; the move/context-menu hit-test iterates them top→bottom, keeping the two + * in lockstep so the layer the user clicks is the layer they see on top. + */ +export const layerGroupRank = (layer: CanvasLayerContract): number => { + switch (layer.type) { + case 'control': + return 1; + case 'regional_guidance': + return 2; + case 'inpaint_mask': + return 3; + default: + // raster (and any future non-grouped renderable) composites at the bottom. + return 0; + } +}; + +/** + * A mask layer's alpha bitmap viewed as a `paint` source, so the whole paint + * pipeline (rasterize, content-sized cache, growth, stroke, persistence) can + * operate on masks unchanged: the mask stores an alpha stencil exactly like a + * paint bitmap; only the compositor differs (it colorizes the alpha with the + * layer's `fill` instead of blitting the RGBA directly). Returns `null` for a + * non-mask layer. + */ +export const maskAsPaintSource = ( + layer: CanvasLayerContract +): Extract | null => + isMaskLayer(layer) ? { bitmap: layer.mask.bitmap, offset: layer.mask.offset, type: 'paint' } : null; + +/** + * A layer's rasterizable source: its own `source` for raster/control layers, or + * a synthetic `paint` view of a mask layer's alpha bitmap. `null` for layers + * with neither (there are none today). This is the single "what pixels does this + * layer hold" accessor the engine's rasterize / cache / persistence paths share. + */ +export const renderableSourceOf = (layer: CanvasLayerContract): CanvasLayerSourceContract | null => { + if (hasSource(layer)) { + return layer.source; + } + return maskAsPaintSource(layer); +}; + +/** + * True when a layer's source is one the engine can rasterize today: image, + * paint, gradient, text, or a rect/ellipse shape. A `polygon` shape has no + * rasterizer yet (deferred), so it is not renderable. + */ +export const isRenderableLayer = (layer: CanvasLayerContract): boolean => { + if (!layer.isEnabled) { + return false; + } + // Mask layers are renderable whenever enabled: an empty (bitmap-less) mask + // rasterizes to a zero-rect surface (skipped downstream, like empty paint). + if (isMaskLayer(layer)) { + return true; + } + if (!hasSource(layer)) { + return false; + } + const { source } = layer; + switch (source.type) { + case 'image': + case 'paint': + case 'gradient': + case 'text': + return true; + case 'shape': + return source.kind !== 'polygon'; + default: + return false; + } +}; + +/** + * True when a layer carries pixels the engine can rasterize AND merge: an + * enabled, unlocked paint/image raster layer. Narrower than + * {@link isRenderableLayer} — masks, control layers, shapes, text, and gradient + * layers are all renderable but NOT mergeable. Locked matches the paint tool's + * rule (`isLocked || !isEnabled` refuses a paint target): merge writes pixels + * into the below layer and deletes the upper one, so it must refuse a locked + * layer on either side just as painting refuses a locked target. The layers + * panel's merge-down enablement (`canMergeLayerDown`) and the engine's + * `mergeLayerDown` guard both defer to this single predicate so the hotkey, + * context menu, and engine can never disagree about what is mergeable. + */ +export const isMergeableRasterLayer = (layer: CanvasLayerContract): boolean => + layer.isEnabled && + !layer.isLocked && + layer.type === 'raster' && + (layer.source.type === 'paint' || layer.source.type === 'image'); + +/** + * A layer's content rectangle in its LOCAL (untransformed) coordinate space — + * the extent its raster cache surface covers, before the layer transform is + * applied. Every layer type is content-sized: + * + * - `image`: `[0, 0, w, h]` at the image's native pixels. + * - `shape` / `text`: `[0, 0, w, h]` at the source's own / estimated extent. + * - `gradient`: `[0, 0, width, height]` from the explicit extent, defaulting to + * the document dims for legacy gradients that predate the extent field. + * - `paint`: the persisted bitmap's dims at its `offset` (legacy default `0, 0`), + * or an EMPTY rect when the layer has no bitmap yet (a brand-new paint layer). + * + * Throws for layers without a rasterizable source, so callers fail loudly. + */ +export const getSourceContentRect = (layer: CanvasLayerContract, doc: CanvasDocumentContractV2): Rect => { + const source = renderableSourceOf(layer); + if (!source) { + throw new Error(`getSourceContentRect: layer type '${layer.type}' has no rasterizable source`); + } + switch (source.type) { + case 'image': + return { height: source.image.height, width: source.image.width, x: 0, y: 0 }; + case 'shape': + return { + height: Math.max(1, Math.round(source.height)), + width: Math.max(1, Math.round(source.width)), + x: 0, + y: 0, + }; + case 'text': { + const extent = estimateTextExtent(source); + return { height: extent.height, width: extent.width, x: 0, y: 0 }; + } + case 'gradient': { + // Explicit extent when present; legacy gradients (no extent) were + // document-sized by construction, so default to the document dims. + const width = source.width ?? doc.width; + const height = source.height ?? doc.height; + return { height, width, x: 0, y: 0 }; + } + case 'paint': { + if (!source.bitmap) { + // A brand-new / cleared paint layer holds no pixels: an empty rect. + return { height: 0, width: 0, x: 0, y: 0 }; + } + const offset = source.offset ?? { x: 0, y: 0 }; + return { height: source.bitmap.height, width: source.bitmap.width, x: offset.x, y: offset.y }; + } + } +}; + +/** + * A layer's axis-aligned bounds in DOCUMENT space: its {@link getSourceContentRect} + * projected through the layer transform (rotation-aware). Used for culling and + * fit-to-content. Throws for layers without a rasterizable source. + */ +export const getSourceBounds = (layer: CanvasLayerContract, doc: CanvasDocumentContractV2): Rect => { + const contentRect = getSourceContentRect(layer, doc); + const { transform } = layer; + const matrix = fromTRS({ x: transform.x, y: transform.y }, transform.rotation, transform.scaleX, transform.scaleY); + return transformBounds(matrix, contentRect); +}; + +/** The native (unscaled) pixel size of a layer's raster cache surface. */ +export const getSourcePixelSize = ( + layer: CanvasLayerContract, + doc: CanvasDocumentContractV2 +): { width: number; height: number } => { + const rect = getSourceContentRect(layer, doc); + return { height: rect.height, width: rect.width }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/editGate.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/editGate.test.ts new file mode 100644 index 00000000000..8b1307fcc5a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/editGate.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { createCanvasEditGate } from './editGate'; + +describe('createCanvasEditGate', () => { + it('grants one exclusive lease and makes release idempotently stale', () => { + const gate = createCanvasEditGate(); + const lease = gate.tryAcquire({ kind: 'filter', layerId: 'a' }); + expect(lease).not.toBeNull(); + expect(gate.tryAcquire({ kind: 'select-object', layerId: 'b' })).toBeNull(); + expect(lease!.isCurrent()).toBe(true); + + lease!.release(); + lease!.release(); + expect(lease!.signal.aborted).toBe(true); + expect(lease!.isCurrent()).toBe(false); + expect(gate.tryAcquire({ kind: 'select-object', layerId: 'b' })).not.toBeNull(); + }); + + it('invalidates leases on document replacement, project invalidation, cooldown, and disposal', () => { + const gate = createCanvasEditGate(); + for (const invalidate of [gate.invalidateDocument, gate.invalidateProject]) { + const lease = gate.tryAcquire({ kind: 'test' }); + expect(lease).not.toBeNull(); + invalidate(); + expect(lease!.signal.aborted).toBe(true); + expect(lease!.isCurrent()).toBe(false); + } + + const coolingLease = gate.tryAcquire({ kind: 'cooling' })!; + gate.cooldown(); + expect(coolingLease.isCurrent()).toBe(false); + expect(gate.tryAcquire({ kind: 'while-cooling' })).toBeNull(); + gate.activate(); + + const disposedLease = gate.tryAcquire({ kind: 'disposed' })!; + gate.dispose(); + expect(disposedLease.isCurrent()).toBe(false); + expect(gate.tryAcquire({ kind: 'after-dispose' })).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/editGate.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/editGate.ts new file mode 100644 index 00000000000..96ca42bee1c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/editGate.ts @@ -0,0 +1,91 @@ +export interface CanvasEditIdentity { + readonly kind: string; + readonly layerId?: string; +} + +export interface CanvasEditLease { + readonly signal: AbortSignal; + isCurrent(): boolean; + release(): void; +} + +export interface CanvasEditGate { + tryAcquire(identity: CanvasEditIdentity): CanvasEditLease | null; +} + +export interface CanvasEditGateController extends CanvasEditGate { + activate(): void; + cooldown(): void; + invalidateDocument(): void; + invalidateProject(): void; + invalidateLayer(layerId: string): void; + dispose(): void; +} + +interface ActiveLease { + readonly controller: AbortController; + readonly generation: number; + readonly identity: CanvasEditIdentity; +} + +export const createCanvasEditGate = (): CanvasEditGateController => { + let active: ActiveLease | null = null; + let generation = 0; + let lifecycle: 'active' | 'cooling' | 'disposed' = 'active'; + + const invalidate = (): void => { + generation += 1; + const lease = active; + active = null; + lease?.controller.abort(); + }; + + const tryAcquire = (_identity: CanvasEditIdentity): CanvasEditLease | null => { + if (lifecycle !== 'active' || active) { + return null; + } + const lease: ActiveLease = { controller: new AbortController(), generation, identity: _identity }; + active = lease; + const isCurrent = (): boolean => + lifecycle === 'active' && active === lease && generation === lease.generation && !lease.controller.signal.aborted; + return { + isCurrent, + release: () => { + if (active === lease) { + invalidate(); + } + }, + signal: lease.controller.signal, + }; + }; + + return { + activate: () => { + if (lifecycle !== 'disposed') { + lifecycle = 'active'; + } + }, + cooldown: () => { + if (lifecycle === 'disposed') { + return; + } + lifecycle = 'cooling'; + invalidate(); + }, + dispose: () => { + if (lifecycle === 'disposed') { + return; + } + lifecycle = 'disposed'; + invalidate(); + }, + invalidateDocument: invalidate, + invalidateLayer: (layerId) => { + if (active?.identity.layerId === layerId) { + invalidate(); + } + }, + invalidateProject: invalidate, + tryAcquire, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/editing/controlPixelEdit.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/editing/controlPixelEdit.test.ts new file mode 100644 index 00000000000..b23528e7f1b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/editing/controlPixelEdit.test.ts @@ -0,0 +1,179 @@ +import type { CanvasControlLayerContract, CanvasLayerContract } from '@workbench/types'; + +import { createTestStubRasterBackend, type StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it } from 'vitest'; + +import { + bakeControlPixelEditSurface, + buildMaterializedControlLayer, + decideControlPixelEdit, + isLayerPixelEditEligible, +} from './controlPixelEdit'; + +const control = (overrides: Partial = {}): CanvasControlLayerContract => ({ + adapter: { beginEndStepPct: [0.1, 0.9], controlMode: 'more_control', kind: 'controlnet', model: 'm', weight: 0.7 }, + blendMode: 'screen', + filter: { settings: { low: 10 }, type: 'canny' }, + id: 'control', + isEnabled: true, + isLocked: false, + name: 'Control', + opacity: 0.6, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: true, + ...overrides, +}); + +describe('decideControlPixelEdit', () => { + it.each([ + ['locked', control({ isLocked: true }), true, true, 'locked'], + ['disabled', control({ isEnabled: false }), true, true, 'disabled'], + [ + 'unsupported polygon', + control({ + source: { + fill: '#fff', + height: 10, + kind: 'polygon', + points: [], + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 10, + }, + }), + true, + true, + 'unsupported', + ], + [ + 'stale image', + control({ source: { image: { height: 10, imageName: 'image', width: 10 }, type: 'image' } }), + true, + false, + 'not-ready', + ], + ] as const)('rejects a %s control', (_scenario, layer, hasSourceContent, isCacheReady, reason) => { + expect(decideControlPixelEdit({ hasSourceContent, isCacheReady, layer })).toEqual({ reason, status: 'rejected' }); + }); + + it('edits an empty identity paint control directly', () => { + expect(decideControlPixelEdit({ hasSourceContent: false, isCacheReady: true, layer: control() })).toEqual({ + status: 'direct', + }); + }); + + it.each([ + control({ source: { image: { height: 10, imageName: 'image', width: 10 }, type: 'image' } }), + control({ transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 12, y: 8 } }), + ])('materializes a ready non-direct control', (layer) => { + expect(decideControlPixelEdit({ hasSourceContent: true, isCacheReady: true, layer })).toEqual({ + status: 'materialize', + }); + }); +}); + +describe('isLayerPixelEditEligible', () => { + const rasterPaint: CanvasLayerContract = { + blendMode: 'normal', + id: 'raster', + isEnabled: true, + isLocked: false, + name: 'Raster', + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }; + + it.each([ + ['raster paint', rasterPaint, true], + ['paint control', control(), true], + [ + 'image control', + control({ source: { image: { height: 10, imageName: 'control-image', width: 10 }, type: 'image' } }), + true, + ], + [ + 'rectangle control', + control({ + source: { fill: '#fff', height: 10, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 10 }, + }), + true, + ], + [ + 'polygon control', + control({ + source: { + fill: '#fff', + height: 10, + kind: 'polygon', + points: [], + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 10, + }, + }), + false, + ], + [ + 'raster image', + { ...rasterPaint, source: { image: { height: 10, imageName: 'i', width: 10 }, type: 'image' } }, + false, + ], + ['locked control', control({ isLocked: true }), false], + ['disabled control', control({ isEnabled: false }), false], + ['missing layer', undefined, false], + ] as const)('returns %s eligibility for %s', (_scenario, layer, expected) => { + expect(isLayerPixelEditEligible(layer)).toBe(expected); + }); +}); + +describe('buildMaterializedControlLayer', () => { + it('changes only source and transform', () => { + const before = control({ + source: { image: { height: 10, imageName: 'image', width: 10 }, type: 'image' }, + transform: { rotation: Math.PI / 2, scaleX: 2, scaleY: 1, x: 30, y: 40 }, + }); + const after = buildMaterializedControlLayer(before, { height: 20, width: 10, x: 20, y: 40 }); + + expect(after).toEqual({ + ...before, + source: { bitmap: null, offset: { x: 20, y: 40 }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + }); + expect(before.source.type).toBe('image'); + }); +}); + +describe('bakeControlPixelEditSurface', () => { + it('bakes offset source pixels through the complete translated, rotated, and scaled transform', () => { + const backend = createTestStubRasterBackend(); + const source = backend.createSurface(10, 5); + const sourceRect = { height: 5, width: 10, x: 5, y: -4 }; + const baked = bakeControlPixelEditSurface({ + backend, + source, + sourceRect, + transform: { rotation: Math.PI / 2, scaleX: 2, scaleY: 3, x: 7, y: 11 }, + }); + + expect(baked.rect).toEqual({ height: 20, width: 15, x: 4, y: 21 }); + expect(baked.surface.width).toBe(15); + expect(baked.surface.height).toBe(20); + expect((baked.surface as StubRasterSurface).callLog).toEqual([ + { args: [1, 0, 0, 1, 0, 0], op: 'setTransform' }, + { args: [0, 0, 15, 20], op: 'clearRect' }, + { args: ['imageSmoothingEnabled', true], op: 'set' }, + { + args: [2 * Math.cos(Math.PI / 2), 2, -3, 3 * Math.cos(Math.PI / 2), 3, -10], + op: 'setTransform', + }, + { args: [source.canvas, sourceRect.x, sourceRect.y], op: 'drawImage' }, + { args: [1, 0, 0, 1, 0, 0], op: 'setTransform' }, + ]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/editing/controlPixelEdit.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/editing/controlPixelEdit.ts new file mode 100644 index 00000000000..8221c82e645 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/editing/controlPixelEdit.ts @@ -0,0 +1,92 @@ +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasControlLayerContract, CanvasLayerContract } from '@workbench/types'; + +import { roundOut, transformBounds } from '@workbench/canvas-engine/math/rect'; +import { bakeMatrix, IDENTITY_TRANSFORM, type LayerTransform } from '@workbench/canvas-engine/transform/transformMath'; + +export type ControlPixelEditRejectedReason = 'disabled' | 'locked' | 'not-ready' | 'unsupported'; +export type ControlPixelEditDecision = + | { status: 'direct' } + | { status: 'materialize' } + | { status: 'rejected'; reason: ControlPixelEditRejectedReason }; + +export interface DecideControlPixelEditInput { + layer: CanvasControlLayerContract; + hasSourceContent: boolean; + isCacheReady: boolean; +} + +const isIdentity = (transform: LayerTransform): boolean => + transform.x === 0 && + transform.y === 0 && + transform.scaleX === 1 && + transform.scaleY === 1 && + transform.rotation === 0; + +const isRasterizable = (layer: CanvasControlLayerContract): boolean => + layer.source.type !== 'shape' || layer.source.kind !== 'polygon'; + +export const isLayerPixelEditEligible = (layer: CanvasLayerContract | undefined): boolean => + !!layer && + !layer.isLocked && + layer.isEnabled && + ((layer.type === 'raster' && layer.source.type === 'paint') || (layer.type === 'control' && isRasterizable(layer))); + +export const decideControlPixelEdit = ({ + hasSourceContent, + isCacheReady, + layer, +}: DecideControlPixelEditInput): ControlPixelEditDecision => { + if (layer.isLocked) { + return { reason: 'locked', status: 'rejected' }; + } + if (!layer.isEnabled) { + return { reason: 'disabled', status: 'rejected' }; + } + if (!isRasterizable(layer)) { + return { reason: 'unsupported', status: 'rejected' }; + } + if (hasSourceContent && !isCacheReady) { + return { reason: 'not-ready', status: 'rejected' }; + } + if (layer.source.type === 'paint' && isIdentity(layer.transform)) { + return { status: 'direct' }; + } + return { status: 'materialize' }; +}; + +export const buildMaterializedControlLayer = ( + layer: CanvasControlLayerContract, + rect: Rect +): CanvasControlLayerContract => ({ + ...structuredClone(layer), + source: { bitmap: null, offset: { x: rect.x, y: rect.y }, type: 'paint' }, + transform: { ...IDENTITY_TRANSFORM }, +}); + +export interface BakeControlPixelEditSurfaceInput { + backend: RasterBackend; + source: RasterSurface; + sourceRect: Rect; + transform: LayerTransform; +} + +export const bakeControlPixelEditSurface = ({ + backend, + source, + sourceRect, + transform, +}: BakeControlPixelEditSurfaceInput): { rect: Rect; surface: RasterSurface } => { + const matrix = bakeMatrix(transform); + const rect = roundOut(transformBounds(matrix, sourceRect)); + const surface = backend.createSurface(rect.width, rect.height); + const ctx = surface.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, rect.width, rect.height); + ctx.imageSmoothingEnabled = true; + ctx.setTransform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e - rect.x, matrix.f - rect.y); + ctx.drawImage(source.canvas, sourceRect.x, sourceRect.y); + ctx.setTransform(1, 0, 0, 1, 0, 0); + return { rect, surface }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.mask.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.mask.test.ts new file mode 100644 index 00000000000..f7687c4aff9 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.mask.test.ts @@ -0,0 +1,595 @@ +/** + * Inpaint mask engine behaviour: painting into a mask's alpha cache, persisting + * the mask bitmap through the bitmap store, and the in-place mask invert. + * + * Isolated in its own file so it can `vi.mock` the canvas-image upload seam + * (the engine's INTERNAL bitmap store uses it) without touching the rest of the + * engine test suite. + */ + +import type { StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { CanvasProjectMutationPort } from '@workbench/canvasProjectMutationPort'; +import type { CanvasDocumentContractV2, CanvasStateContractV2, Project, WorkbenchState } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { + applyCanvasProjectMutation, + isCanvasProjectMutation, + type CanvasProjectMutation, +} from '@workbench/canvasProjectMutations'; + +type EngineTestAction = WorkbenchAction | CanvasProjectMutation; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { + createCanvasEngine as createApplicationCanvasEngine, + type CanvasEngineOptions, +} from '@workbench/canvas-operations/createCanvasEngine'; +import { createInitialWorkbenchState, workbenchReducer } from '@workbench/workbenchState'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { StrokeCommittedEvent } from './tools/tool'; + +interface EngineStore { + dispatch(action: EngineTestAction): void; + getState(): WorkbenchState; + subscribe(listener: () => void): () => void; +} + +const createMutationPort = (store: EngineStore, projectId: string): CanvasProjectMutationPort => ({ + dispatch: (mutation) => { + const before = store.getState().projects.find((project) => project.id === projectId)?.canvas ?? null; + if (!before) { + return false; + } + store.dispatch(mutation); + return store.getState().projects.find((project) => project.id === projectId)?.canvas !== before; + }, + getCanvasState: () => store.getState().projects.find((project) => project.id === projectId)?.canvas ?? null, + subscribe: store.subscribe, +}); + +const createCanvasEngine = ({ + projectId, + store, + ...options +}: Omit & { store: EngineStore }) => + createApplicationCanvasEngine({ + ...options, + mutationPort: createMutationPort(store, projectId), + projectId, + reportError: () => undefined, + }); + +vi.mock('@workbench/canvas-operations/backend/canvasImages', () => ({ + CanvasImageUploadError: class extends Error {}, + uploadCanvasImage: vi.fn(() => Promise.resolve({ height: 64, imageName: 'mask-img', width: 64 })), +})); + +const makeCanvas = (document: CanvasDocumentContractV2, documentRevision = 0): CanvasStateContractV2 => + ({ + document, + documentRevision, + snapshots: [], + stagingArea: { + areThumbnailsVisible: false, + autoSwitchMode: 'off', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + version: 2, + }) as CanvasStateContractV2; + +const maskDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'normal', + id: 'mask1', + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + name: 'Inpaint Mask 1', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', + }, + ], + selectedLayerId: 'mask1', + version: 2, + width: 100, +}); + +const createReactiveStore = (document: CanvasDocumentContractV2) => { + let state = { + activeProjectId: 'p1', + projects: [{ canvas: makeCanvas(document), id: 'p1' }], + } as unknown as WorkbenchState; + const listeners = new Set<() => void>(); + const dispatch = vi.fn((action: EngineTestAction) => { + if (isCanvasProjectMutation(action)) { + const project = state.projects[0] as unknown as Project; + state = { + ...state, + projects: [applyCanvasProjectMutation(project, action)], + } as WorkbenchState; + } + for (const listener of listeners) { + listener(); + } + }); + return { + dispatch, + setDocument: (next: CanvasDocumentContractV2, revision = 0) => { + state = { + activeProjectId: 'p1', + projects: [{ canvas: makeCanvas(next, revision), id: 'p1' }], + } as unknown as WorkbenchState; + for (const listener of listeners) { + listener(); + } + }, + store: { + dispatch, + getState: () => state, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + } as EngineStore, + }; +}; + +const createReducerBackedStore = (document: CanvasDocumentContractV2) => { + let state = createInitialWorkbenchState(); + const projectId = state.activeProjectId; + state = workbenchReducer(state, { + mutation: { document, type: 'replaceCanvasDocument' }, + projectId, + type: 'applyCanvasProjectMutation', + }); + const listeners = new Set<() => void>(); + const dispatch = vi.fn((action: EngineTestAction) => { + state = workbenchReducer( + state, + isCanvasProjectMutation(action) ? { mutation: action, projectId, type: 'applyCanvasProjectMutation' } : action + ); + for (const listener of listeners) { + listener(); + } + }); + return { + projectId, + store: { + dispatch, + getState: () => state, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + } as EngineStore, + }; +}; + +const createControllableRaf = () => { + let nextHandle = 1; + const callbacks = new Map(); + return { + cancelFrame: (handle: number) => callbacks.delete(handle), + flush: () => { + const queued = [...callbacks.values()]; + callbacks.clear(); + for (const cb of queued) { + cb(0); + } + }, + requestFrame: (cb: FrameRequestCallback) => { + const handle = nextHandle++; + callbacks.set(handle, cb); + return handle; + }, + }; +}; + +const createInputCanvas = (width = 100, height = 100) => { + const surface = createTestStubRasterBackend().createSurface(width, height); + const listeners = new Map void>>(); + const element = { + addEventListener: (type: string, handler: (event: Event) => void) => { + const set = listeners.get(type) ?? new Set(); + set.add(handler); + listeners.set(type, set); + }, + getBoundingClientRect: () => ({ bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0 }), + getContext: () => surface.ctx, + height, + releasePointerCapture: () => {}, + removeEventListener: (type: string, handler: (event: Event) => void) => { + listeners.get(type)?.delete(handler); + }, + setPointerCapture: () => {}, + width, + } as unknown as HTMLCanvasElement; + const fire = (type: string, event: Partial) => { + for (const handler of listeners.get(type) ?? []) { + handler({ preventDefault: () => {}, ...event } as unknown as Event); + } + }; + return { element, fire }; +}; + +const pointerAt = (x: number, y: number, buttons = 1): Partial => + ({ + altKey: false, + button: 0, + buttons, + clientX: x, + clientY: y, + ctrlKey: false, + metaKey: false, + pointerId: 1, + pointerType: 'mouse', + pressure: 0.5, + shiftKey: false, + timeStamp: 0, + }) as Partial; + +const setupEngine = (doc: CanvasDocumentContractV2) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const reactive = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store: reactive.store, + }); + const strokes: StrokeCommittedEvent[] = []; + engine.tools.onStrokeCommitted((event) => strokes.push(event)); + + const screen = createInputCanvas(); + const overlay = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + + return { dispatch: reactive.dispatch, engine, overlay, raf, setDocument: reactive.setDocument, strokes }; +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('inpaint mask painting', () => { + it('routes a brush stroke into the selected mask cache (no auto-created paint layer)', () => { + const { dispatch, engine, overlay, strokes } = setupEngine(maskDoc()); + engine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, 0)); + + expect(strokes).toHaveLength(1); + expect(strokes[0]!.layerId).toBe('mask1'); + expect(strokes[0]!.tool).toBe('brush'); + // A mask target must NOT spawn a fresh paint layer (the auto-create path). + const added = dispatch.mock.calls.map((c) => c[0]).filter((a) => a.type === 'addCanvasLayer'); + expect(added).toHaveLength(0); + engine.lifecycle.dispose(); + }); + + it('erases from the mask cache with the eraser (destination-out) as a committed stroke', () => { + const { engine, overlay, strokes } = setupEngine(maskDoc()); + // Paint some coverage first. + engine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(60, 60)); + overlay.fire('pointerup', pointerAt(60, 60, 0)); + // Then erase. + engine.tools.setTool('eraser'); + overlay.fire('pointerdown', pointerAt(30, 30)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, 0)); + + expect(strokes).toHaveLength(2); + expect(strokes[1]!.tool).toBe('eraser'); + expect(strokes[1]!.layerId).toBe('mask1'); + engine.lifecycle.dispose(); + }); + + it('does not paint into a locked mask', () => { + const doc = maskDoc(); + doc.layers[0]!.isLocked = true; + const { dispatch, engine, overlay, strokes } = setupEngine(doc); + engine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, 0)); + + expect(strokes).toHaveLength(0); + // Also never spawns a paint layer over the locked mask. + expect(dispatch.mock.calls.map((c) => c[0]).some((a) => a.type === 'addCanvasLayer')).toBe(false); + engine.lifecycle.dispose(); + }); + + it('persists the mask via updateCanvasLayerConfig (bitmap + offset) after a stroke flush', async () => { + const { dispatch, engine, overlay } = setupEngine(maskDoc()); + engine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(50, 50)); + overlay.fire('pointerup', pointerAt(50, 50, 0)); + + await engine.lifecycle.flushPendingUploads(); + + const configDispatch = dispatch.mock.calls + .map((c) => c[0]) + .find( + (a): a is Extract => + a.type === 'updateCanvasLayerConfig' && a.id === 'mask1' + ); + expect(configDispatch).toBeDefined(); + const config = configDispatch!.config; + expect(config.layerType).toBe('inpaint_mask'); + // The mask bitmap ref + its content offset are dispatched (not an image source). + expect(config).toHaveProperty('mask'); + const mask = (config as { mask: { bitmap: unknown; offset: unknown } }).mask; + expect(mask.bitmap).toMatchObject({ imageName: 'mask-img' }); + expect(mask.offset).toBeDefined(); + // The mask persistence must NEVER dispatch a paint source (that would convert + // the mask into a raster paint layer). + expect(dispatch.mock.calls.map((c) => c[0]).some((a) => a.type === 'updateCanvasLayerSource')).toBe(false); + engine.lifecycle.dispose(); + }); +}); + +describe('mask invert', () => { + it('inverts a mask as an undoable op and returns true', () => { + const { engine } = setupEngine(maskDoc()); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.layers.invertMask('mask1')).toBe(true); + // One undoable image patch was recorded. + expect(engine.stores.canUndo.get()).toBe(true); + // Round trips: undo then redo restore without throwing. + engine.history.undo(); + expect(engine.stores.canRedo.get()).toBe(true); + engine.history.redo(); + expect(engine.stores.canUndo.get()).toBe(true); + engine.lifecycle.dispose(); + }); + + it('returns false for a missing layer, a non-mask layer, or a locked mask', () => { + const lockedDoc = maskDoc(); + lockedDoc.layers[0]!.isLocked = true; + const { engine } = setupEngine(lockedDoc); + expect(engine.layers.invertMask('nope')).toBe(false); + expect(engine.layers.invertMask('mask1')).toBe(false); // locked + engine.lifecycle.dispose(); + }); + + // Regression: the invert domain used to come ONLY from `getSourceContentRect`, + // which reads the persisted `mask.bitmap` — stale/null until the debounced + // bitmap-store flush runs. A stroke painted moments earlier (already reflected + // in the live `layerCache`, not yet flushed to the contract) that extends past + // the document bbox was silently excluded from the invert, so its pixels never + // got flipped. The domain must union in the live cache rect too. + it('unions the live (unflushed) cache rect into the invert domain, covering an out-of-bbox stroke', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const reactive = createReactiveStore(maskDoc()); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store: reactive.store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + + engine.tools.setTool('brush'); + // Paint well outside the document bbox (0,0,100,100 per `maskDoc`). The live + // cache grows to cover the stroke immediately; deliberately never call + // `flushPendingUploads()`, so the contract's `mask.bitmap` stays null and + // `getSourceContentRect` alone would report an empty content rect. + overlay.fire('pointerdown', pointerAt(150, 150)); + overlay.fire('pointermove', pointerAt(180, 180)); + overlay.fire('pointerup', pointerAt(180, 180, 0)); + + expect(engine.layers.invertMask('mask1')).toBe(true); + + // Growing the cache (both while painting and inside `invertMask` itself) + // reallocates a fresh backing surface each time its extent changes, so + // `surfaces` holds one entry per intermediate size, not one per layer. The + // invert's own read/write always lands on the LAST surface that gets a + // `putImageData` — the final, invert-sized surface — so pick that one + // rather than the first surface that happens to have a `getImageData` + // (which would be a stale, already-abandoned paint-time surface). + const putSurfaces = surfaces.filter((surface) => surface.callLog.some((entry) => entry.op === 'putImageData')); + const maskCache = putSurfaces[putSurfaces.length - 1]; + expect(maskCache).toBeDefined(); + const getCalls = maskCache!.callLog.filter((entry) => entry.op === 'getImageData'); + expect(getCalls.length).toBeGreaterThan(0); + const [sx, sy, sw, sh] = getCalls[getCalls.length - 1]!.args as [number, number, number, number]; + // A domain of just the document bbox (0,0,100,100) — what `getSourceContentRect` + // alone would report, since `mask.bitmap` is still null pre-flush — would read + // a region that ends at x/y 100. Unioning in the live cache rect must grow the + // domain to also cover the stroke out at document (150,150)-(180,180), well + // past that 100 bound on both axes. + expect(sx + sw).toBeGreaterThan(110); + expect(sy + sh).toBeGreaterThan(110); + + engine.lifecycle.dispose(); + }); + + it('extracts through a live unflushed mask whose contract bitmap is still null', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + const doc = maskDoc(); + doc.layers.push({ + blendMode: 'normal', + id: 'raster1', + isEnabled: true, + isLocked: false, + name: 'Raster 1', + opacity: 1, + source: { image: { height: 100, imageName: 'raster-image', width: 100 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }); + const reducer = createReducerBackedStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: reducer.projectId, + store: reducer.store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + raf.flush(); + + engine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(50, 50)); + overlay.fire('pointerup', pointerAt(50, 50, 0)); + + expect(await engine.exports.extractMaskedArea('mask1')).toMatchObject({ status: 'extracted' }); + engine.lifecycle.dispose(); + }); +}); + +describe('mask clear', () => { + it('clears a live unflushed inpaint mask and restores it through undo', () => { + const { engine, overlay } = setupEngine(maskDoc()); + engine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(50, 50)); + overlay.fire('pointerup', pointerAt(50, 50, 0)); + + expect(engine.layers.clearMask('mask1')).toBe(true); + expect(engine.layers.clearMask('mask1')).toBe(false); + expect(engine.stores.canUndo.get()).toBe(true); + + engine.history.undo(); + expect(engine.stores.canRedo.get()).toBe(true); + engine.history.redo(); + expect(engine.layers.clearMask('mask1')).toBe(false); + engine.history.undo(); + expect(engine.layers.clearMask('mask1')).toBe(true); + engine.lifecycle.dispose(); + }); + + it('clears a regional-guidance mask', () => { + const doc = maskDoc(); + doc.layers[0] = { + autoNegative: false, + blendMode: 'normal', + id: 'mask1', + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + name: 'Region 1', + negativePrompt: null, + opacity: 1, + positivePrompt: null, + referenceImages: [], + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'regional_guidance', + }; + const { engine, overlay } = setupEngine(doc); + engine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(50, 50)); + overlay.fire('pointerup', pointerAt(50, 50, 0)); + + expect(engine.layers.clearMask('mask1')).toBe(true); + engine.lifecycle.dispose(); + }); + + it('clears a cold hidden persisted mask and restores its bitmap reference on undo', () => { + const doc = maskDoc(); + const mask = doc.layers[0]!; + if (mask.type !== 'inpaint_mask') { + throw new Error('Expected inpaint mask fixture'); + } + mask.isEnabled = false; + mask.mask = { + ...mask.mask, + bitmap: { height: 40, imageName: 'persisted-mask', width: 50 }, + offset: { x: 7, y: 9 }, + }; + const { dispatch, engine } = setupEngine(doc); + + expect(engine.layers.clearMask('mask1')).toBe(true); + expect(dispatch.mock.calls.at(-1)?.[0]).toMatchObject({ + config: { mask: { bitmap: null } }, + id: 'mask1', + type: 'updateCanvasLayerConfig', + }); + + engine.history.undo(); + expect(dispatch.mock.calls.at(-1)?.[0]).toMatchObject({ + config: { + mask: { bitmap: { imageName: 'persisted-mask' }, offset: { x: 7, y: 9 } }, + }, + id: 'mask1', + type: 'updateCanvasLayerConfig', + }); + engine.lifecycle.dispose(); + }); + + it('refuses missing, non-mask, locked, and empty layers', () => { + const locked = maskDoc(); + locked.layers[0]!.isLocked = true; + const { engine } = setupEngine(locked); + + expect(engine.layers.clearMask('missing')).toBe(false); + expect(engine.layers.clearMask('mask1')).toBe(false); + engine.lifecycle.dispose(); + + const raster = maskDoc(); + raster.layers[0] = { + blendMode: 'normal', + id: 'mask1', + isEnabled: true, + isLocked: false, + name: 'Raster 1', + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }; + const { engine: rasterEngine } = setupEngine(raster); + expect(rasterEngine.layers.clearMask('mask1')).toBe(false); + rasterEngine.lifecycle.dispose(); + + const { engine: emptyEngine } = setupEngine(maskDoc()); + expect(emptyEngine.layers.clearMask('mask1')).toBe(false); + emptyEngine.lifecycle.dispose(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts new file mode 100644 index 00000000000..090b2d4f4f9 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts @@ -0,0 +1,16714 @@ +import type * as ImagePatchModule from '@workbench/canvas-engine/history/imagePatch'; +import type * as LayerSnapshotModule from '@workbench/canvas-engine/history/layerSnapshot'; +import type * as AdjustedSurfaceCacheModule from '@workbench/canvas-engine/render/adjustedSurfaceCache'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { + RasterCallLogEntry, + StubRasterBackend, + StubRasterSurface, +} from '@workbench/canvas-engine/render/raster.testStub'; +import type { ToolId } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutationPort } from '@workbench/canvasProjectMutationPort'; +import type { + CanvasControlLayerContract, + CanvasDocumentContractV2, + CanvasInpaintMaskLayerContract, + CanvasLayerContract, + CanvasLayerSourceContract, + CanvasRasterLayerContractV2, + CanvasStagingCandidateContract, + CanvasStateContractV2, + Project, + WorkbenchState, +} from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { + applyCanvasProjectMutation, + isCanvasProjectMutation, + type CanvasProjectMutation, +} from '@workbench/canvasProjectMutations'; + +type EngineTestAction = WorkbenchAction | CanvasProjectMutation; + +import { DEFAULT_CHECKER_COLORS } from '@workbench/canvas-engine/render/compositor'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { canvasApplicationPort } from '@workbench/canvas-operations/applicationPort'; +import { + createCanvasEngine as createApplicationCanvasEngine, + getCanvasOperations, + type CanvasEngine, + type CanvasEngineOptions, +} from '@workbench/canvas-operations/createCanvasEngine'; +import { FILTER_AUTO_PROCESS_DEBOUNCE_MS } from '@workbench/canvas-operations/filterOperationSession'; +import { createInitialWorkbenchState, workbenchReducer } from '@workbench/workbenchState'; +import { afterEach, describe, expect, it, type Mock, vi } from 'vitest'; + +import type { BitmapStore } from './document/bitmapStore'; +import type { StrokeCommittedEvent } from './tools/tool'; + +import { createBitmapStore } from './document/bitmapStore'; +import { mergeDownMatrix } from './document/mergeDown'; + +interface EngineStore { + dispatch(action: EngineTestAction): void; + getState(): WorkbenchState; + reducesCanvasMutations?: true; + subscribe(listener: () => void): () => void; +} + +const createTestMutationPort = (store: EngineStore, projectId: string): CanvasProjectMutationPort => { + const readStoreCanvas = () => store.getState().projects.find((project) => project.id === projectId)?.canvas ?? null; + let canvas = readStoreCanvas(); + let observedStoreCanvas = canvas; + const listeners = new Set<() => void>(); + const unsubscribeStore = store.subscribe(() => { + const next = readStoreCanvas(); + if (next !== observedStoreCanvas) { + observedStoreCanvas = next; + canvas = next; + } + for (const listener of listeners) { + listener(); + } + }); + return { + dispatch: (mutation) => { + const before = canvas; + if (!before) { + return false; + } + store.dispatch(mutation); + if (store.reducesCanvasMutations) { + const reduced = readStoreCanvas(); + observedStoreCanvas = reduced; + canvas = reduced; + } else { + const project = store.getState().projects.find((candidate) => candidate.id === projectId); + canvas = applyCanvasProjectMutation({ ...(project as Project), canvas: before }, mutation).canvas; + for (const listener of listeners) { + listener(); + } + } + return canvas !== before; + }, + getCanvasState: () => { + const current = readStoreCanvas(); + if (current !== observedStoreCanvas) { + observedStoreCanvas = current; + canvas = current; + } + return canvas; + }, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + unsubscribeStore(); + } + }; + }, + }; +}; + +type TestCanvasEngineOptions = Omit & { + getMainModelBase?: () => string | null; + mutationPort?: CanvasProjectMutationPort; + reportError?: CanvasEngineOptions['reportError']; + store: EngineStore; +}; + +const createCanvasEngine = ({ + getMainModelBase, + mutationPort, + projectId, + reportError, + store, + ...options +}: TestCanvasEngineOptions): CanvasEngine => + createApplicationCanvasEngine({ + ...options, + getMainModelBase: + getMainModelBase ?? (() => canvasApplicationPort.getSelectedModelBase(store.getState(), projectId)), + mutationPort: mutationPort ?? createTestMutationPort(store, projectId), + projectId, + reportError: reportError ?? (() => undefined), + }); + +// Records adjusted-surface cache access without exposing it on the engine. The +// factory wraps the real implementation, preserving all behaviour. +const adjustedSurfaceCacheDeletes = vi.hoisted(() => [] as string[]); +const adjustedSurfaceCacheGets = vi.hoisted(() => [] as string[]); +const adjustedSurfaceCacheDeleteFaults = vi.hoisted(() => new Set()); +const historyPreparationFaults = vi.hoisted(() => ({ + imagePatch: false, + imagePatchBefore: null as ImageData | null, + layerSnapshot: false, +})); + +vi.mock('@workbench/canvas-engine/render/adjustedSurfaceCache', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createAdjustedSurfaceCache: (backend: Parameters[0]) => { + const cache = actual.createAdjustedSurfaceCache(backend); + return { + ...cache, + delete: (layerId: string) => { + adjustedSurfaceCacheDeletes.push(layerId); + if (adjustedSurfaceCacheDeleteFaults.has(layerId)) { + adjustedSurfaceCacheDeleteFaults.delete(layerId); + throw new Error('adjusted surface cache delete failed'); + } + cache.delete(layerId); + }, + get: (...args: Parameters) => { + adjustedSurfaceCacheGets.push(args[0]); + return cache.get(...args); + }, + }; + }, + }; +}); + +vi.mock('./history/imagePatch', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createImagePatchEntry: (...args: Parameters) => { + historyPreparationFaults.imagePatchBefore = args[0].before; + if (historyPreparationFaults.imagePatch) { + throw new Error('image patch preparation failed'); + } + return actual.createImagePatchEntry(...args); + }, + }; +}); + +vi.mock('./history/layerSnapshot', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createLayerSnapshotEntry: (...args: Parameters) => { + if (historyPreparationFaults.layerSnapshot) { + throw new Error('layer snapshot preparation failed'); + } + return actual.createLayerSnapshotEntry(...args); + }, + }; +}); + +const rasterLayer = (id: string, opts: { imageName?: string; opacity?: number } = {}): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: opts.opacity ?? 1, + source: { image: { height: 10, imageName: opts.imageName ?? id, width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const makeDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [rasterLayer('a')], + selectedLayerId: null, + version: 2, + width: 100, +}); + +const makeCanvas = (document: CanvasDocumentContractV2, documentRevision = 0): CanvasStateContractV2 => ({ + document, + documentRevision, + snapshots: [], + stagingArea: { + areThumbnailsVisible: false, + autoSwitchMode: 'off', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + version: 2, +}); + +const createFakeStore = ( + document: CanvasDocumentContractV2 +): { store: EngineStore; unsubscribe: ReturnType } => { + const state = { + activeProjectId: 'p1', + projects: [{ canvas: makeCanvas(document), id: 'p1' }], + } as unknown as WorkbenchState; + const unsubscribe = vi.fn(); + return { + store: { + dispatch: vi.fn(), + getState: () => state, + subscribe: () => unsubscribe, + }, + unsubscribe, + }; +}; + +const createEngine = () => { + const doc = makeDoc(); + const { store, unsubscribe } = createFakeStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + return { doc, engine, unsubscribe }; +}; + +// ---- Reactive store + render-loop harness ----------------------------- +// +// The tests above never `attach()`, so the render loop (and thus +// `ensureLayerCaches`) never runs. The tests below exercise the document +// mirror wired into a live engine, so they need: a store that actually +// notifies subscribers on change, a controllable `requestAnimationFrame` +// pair to drive the (otherwise real) scheduler deterministically, and fake +// canvases whose `getContext('2d')` returns a recording stub context. + +/** A reactive fake store: `setDocument` notifies subscribers, unlike `createFakeStore` above. */ +const createReactiveStore = ( + document: CanvasDocumentContractV2 +): { + listenerCount: () => number; + setActiveProjectId: (projectId: string) => void; + setDocument: (next: CanvasDocumentContractV2, documentRevision?: number) => void; + store: EngineStore; +} => { + let revision = 0; + let activeProjectId = 'p1'; + let state = { + activeProjectId, + projects: [{ canvas: makeCanvas(document), id: 'p1' }], + } as unknown as WorkbenchState; + const listeners = new Set<() => void>(); + const notify = (): void => { + for (const listener of listeners) { + listener(); + } + }; + return { + listenerCount: () => listeners.size, + setActiveProjectId: (projectId) => { + activeProjectId = projectId; + state = { ...state, activeProjectId }; + notify(); + }, + setDocument: (next, documentRevision = revision) => { + revision = documentRevision; + state = { + activeProjectId, + projects: [{ canvas: makeCanvas(next, documentRevision), id: 'p1' }], + } as unknown as WorkbenchState; + notify(); + }, + store: { + dispatch: vi.fn(), + getState: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }, + }; +}; + +/** A controllable `requestAnimationFrame`/`cancelAnimationFrame` pair for driving the scheduler. */ +const createControllableRaf = () => { + let nextHandle = 1; + const callbacks = new Map(); + return { + cancelFrame: (handle: number): void => { + callbacks.delete(handle); + }, + /** Runs every currently-queued frame callback, like the browser firing a frame. */ + flush: (): void => { + const queued = [...callbacks.entries()]; + callbacks.clear(); + for (const [, cb] of queued) { + cb(0); + } + }, + pendingCount: (): number => callbacks.size, + requestFrame: (cb: FrameRequestCallback): number => { + const handle = nextHandle++; + callbacks.set(handle, cb); + return handle; + }, + }; +}; + +/** A minimal fake `HTMLCanvasElement`: a recording 2D context, no-op listeners. */ +const createFakeCanvas = (width = 100, height = 100): { element: HTMLCanvasElement; surface: StubRasterSurface } => { + const surface = createTestStubRasterBackend().createSurface(width, height); + const listeners = new Map void>>(); + const element = { + addEventListener: (type: string, handler: (event: Event) => void) => { + let set = listeners.get(type); + if (!set) { + set = new Set(); + listeners.set(type, set); + } + set.add(handler); + }, + getBoundingClientRect: () => ({ + bottom: height, + height, + left: 0, + right: width, + toJSON: () => ({}), + top: 0, + width, + x: 0, + y: 0, + }), + getContext: () => surface.ctx, + height, + releasePointerCapture: () => {}, + removeEventListener: (type: string, handler: (event: Event) => void) => { + listeners.get(type)?.delete(handler); + }, + setPointerCapture: () => {}, + width, + } as unknown as HTMLCanvasElement; + return { element, surface }; +}; + +/** A deferred promise, so a test can resolve a rasterize step on demand. */ +const createDeferred = (): { promise: Promise; resolve: (value: T) => void } => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +const createAbortableImageResolver = () => { + const requests = new Map< + string, + { deferred: ReturnType>; signal: AbortSignal | undefined } + >(); + const resolver = vi.fn((imageName: string, signal?: AbortSignal) => { + const deferred = createDeferred(); + requests.set(imageName, { deferred, signal }); + if (signal?.aborted) { + return Promise.reject(signal.reason); + } + return new Promise((resolve, reject) => { + deferred.promise.then(resolve, reject); + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + return { requests, resolver }; +}; + +type RecordingRasterBackend = StubRasterBackend & { + drawSourcesFor(surface: StubRasterSurface): string[]; + surfaceById(id: string): StubRasterSurface | undefined; + surfaceId(surface: StubRasterSurface): string; +}; + +/** + * Stateful raster backend for publication-race tests. Every scratch/cache + * surface gets a stable id, as do the fake bitmaps supplied by each test. This + * lets a test distinguish a decode drawn into an isolated scratch surface from + * a scratch surface published into the live cache without exposing cache + * internals from the production engine. + */ +const createRecordingRasterBackend = (): RecordingRasterBackend => { + const base = createTestStubRasterBackend(); + let nextSurfaceId = 1; + const surfaces = new Map(); + return { + ...base, + createSurface: (width, height) => { + const surface = base.createSurface(width, height); + const id = `surface-${nextSurfaceId++}`; + Object.assign(surface.canvas, { __recordingId: id }); + surfaces.set(id, surface); + return surface; + }, + drawSourcesFor: (surface) => + surface.callLog + .filter((entry) => entry.op === 'drawImage') + .map((entry) => (entry.args[0] as { __recordingId?: string }).__recordingId ?? 'unknown'), + surfaceById: (id) => surfaces.get(id), + surfaceId: (surface) => (surface.canvas as unknown as { __recordingId: string }).__recordingId, + }; +}; + +const recordingBitmap = (id: string, width = 10, height = 10): ImageBitmap => + ({ __recordingId: `bitmap-${id}`, close: vi.fn(), height, width }) as unknown as ImageBitmap; + +/** One-shot allocation/draw faults, armed after a deterministic number of successful calls. */ +const createStructuralFaultBackend = () => { + const base = createTestStubRasterBackend(); + let allocationCountdown: number | null = null; + let drawCountdown: number | null = null; + const backend: StubRasterBackend = { + ...base, + createSurface: (width, height) => { + if (allocationCountdown !== null) { + if (allocationCountdown === 0) { + allocationCountdown = null; + throw new Error('structural cache allocation failed'); + } + allocationCountdown -= 1; + } + const surface = base.createSurface(width, height); + const ctx = new Proxy(surface.ctx, { + get: (target, property, receiver) => { + const value = Reflect.get(target, property, receiver); + if (property !== 'drawImage' || typeof value !== 'function') { + return value; + } + return (...args: unknown[]) => { + if (drawCountdown !== null) { + if (drawCountdown === 0) { + drawCountdown = null; + throw new Error('structural cache draw failed'); + } + drawCountdown -= 1; + } + return Reflect.apply(value, target, args); + }; + }, + }); + Object.defineProperty(surface, 'ctx', { value: ctx }); + return surface; + }, + }; + + return { + armAllocation: (successfulCreates: number) => { + allocationCountdown = successfulCreates; + }, + armDraw: (successfulDraws: number) => { + drawCountdown = successfulDraws; + }, + backend, + }; +}; + +const createInvalidateDuringBitmapDrawBackend = (bitmap: ImageBitmap, onDraw: () => void): StubRasterBackend => { + const backend = createTestStubRasterBackend(); + let invalidated = false; + return { + ...backend, + createImageBitmap: vi.fn(() => Promise.resolve(bitmap)), + createSurface: (width, height) => { + const surface = backend.createSurface(width, height); + const ctx = new Proxy(surface.ctx, { + get: (target, property, receiver) => { + const value = Reflect.get(target, property, receiver); + if (property !== 'drawImage' || typeof value !== 'function') { + return value; + } + return (...args: unknown[]) => { + const result = Reflect.apply(value, target, args); + if (!invalidated && args[0] === bitmap) { + invalidated = true; + onDraw(); + } + return result; + }; + }, + }); + Object.defineProperty(surface, 'ctx', { value: ctx }); + return surface; + }, + }; +}; + +interface LayerCacheTestSnapshot { + calls: RasterCallLogEntry[]; + rect: { height: number; width: number; x: number; y: number }; + surface: RasterSurface; + version: number; +} + +const snapshotLayerCache = async (engine: ReturnType, layerId: string) => { + const exported = await engine.exports.exportLayerPixels(layerId, { includeDisabled: true }); + if (exported.status !== 'ok') { + throw new Error(`Expected a ready cache for ${layerId}, got ${exported.status}`); + } + return { + calls: structuredClone((exported.surface as StubRasterSurface).callLog), + rect: { ...exported.rect }, + surface: exported.surface, + version: exported.guard.cacheVersion, + } satisfies LayerCacheTestSnapshot; +}; + +const expectLayerCacheExact = async ( + engine: ReturnType, + layerId: string, + expectedSnapshot: LayerCacheTestSnapshot +): Promise => { + const actual = await engine.exports.exportLayerPixels(layerId, { includeDisabled: true }); + expect(actual.status).toBe('ok'); + if (actual.status !== 'ok') { + return; + } + expect(actual.surface).toBe(expectedSnapshot.surface); + expect(actual.rect).toEqual(expectedSnapshot.rect); + expect(actual.guard.cacheVersion).toBe(expectedSnapshot.version); + expect((actual.surface as StubRasterSurface).callLog).toEqual(expectedSnapshot.calls); +}; + +/** Recursively follows recording-surface draw edges to a bitmap/surface id. */ +const drawGraphContains = ( + surface: StubRasterSurface, + backend: RecordingRasterBackend, + targetId: string, + visited = new Set() +): boolean => { + for (const sourceId of backend.drawSourcesFor(surface)) { + if (sourceId === targetId) { + return true; + } + if (visited.has(sourceId)) { + continue; + } + visited.add(sourceId); + const source = backend.surfaceById(sourceId); + if (source && drawGraphContains(source, backend, targetId, visited)) { + return true; + } + } + return false; +}; + +/** Flushes pending microtasks (promise chains) without depending on fake timers. */ +const flushMicrotasks = (): Promise => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +const drainMicrotasksUntil = async (predicate: () => boolean, maxTicks = 100): Promise => { + for (let tick = 0; tick < maxTicks && !predicate(); tick += 1) { + await Promise.resolve(); + } +}; + +afterEach(() => { + adjustedSurfaceCacheDeleteFaults.clear(); + historyPreparationFaults.imagePatch = false; + historyPreparationFaults.imagePatchBefore = null; + historyPreparationFaults.layerSnapshot = false; + vi.unstubAllGlobals(); +}); + +describe('createCanvasEngine', () => { + it('captures a detached clone of the exact reducer canvas contract', () => { + const { doc, engine } = createEngine(); + + const snapshot = engine.document.captureSnapshot(); + + expect(snapshot?.canvas.document).toEqual(doc); + expect(snapshot?.canvas.document).not.toBe(doc); + expect(snapshot?.canvas.documentRevision).toBe(0); + doc.bbox.x = 99; + expect(snapshot?.canvas.document.bbox.x).toBe(0); + engine.lifecycle.dispose(); + }); + + it('returns stale and releases a partial raster snapshot when the document changes during detachment', async () => { + const pending = createDeferred(); + const document = makeDoc(); + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => pending.promise, + projectId: 'p1', + store, + }); + const documentSnapshot = engine.document.captureSnapshot(); + expect(documentSnapshot).not.toBeNull(); + + const capture = engine.exports.captureRasterSnapshot(documentSnapshot!, ['a']); + setDocument({ + ...document, + layers: [{ ...document.layers[0]!, opacity: 0.5 }], + }); + pending.resolve(new Blob(['pixels'])); + + await expect(capture).resolves.toEqual({ status: 'stale' }); + engine.lifecycle.dispose(); + }); + + it('returns stale when direct paint changes cache pixels after the document snapshot', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + const layer = { + ...rasterLayer('a'), + source: { bitmap: { height: 20, imageName: 'paint-pixels', width: 20 }, type: 'paint' as const }, + }; + const document = { ...makeDoc(), layers: [layer], selectedLayerId: layer.id }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + const initial = await engine.exports.exportLayerPixels(layer.id); + expect(initial.status).toBe('ok'); + if (initial.status !== 'ok') { + throw new Error('Expected initial pixels'); + } + const initialGuard = initial.guard; + initial.release(); + const documentSnapshot = engine.document.captureSnapshot(); + const capture = engine.exports.captureRasterSnapshot(documentSnapshot!, [layer.id]); + + engine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(5, 5)); + overlay.fire('pointermove', pointerAt(10, 10)); + overlay.fire('pointerup', pointerAt(10, 10, { buttons: 0 })); + + expect(engine.exports.isLayerExportGuardCurrent(initialGuard)).toBe(false); + await expect(capture).resolves.toEqual({ status: 'stale' }); + engine.lifecycle.dispose(); + }); + + it('returns stale when cooldown changes lifecycle generation during raster detachment', async () => { + const pending = createDeferred(); + const { store } = createReactiveStore(makeDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => pending.promise, + projectId: 'p1', + store, + }); + const documentSnapshot = engine.document.captureSnapshot(); + const capture = engine.exports.captureRasterSnapshot(documentSnapshot!, ['a']); + + const cooldown = engine.lifecycle.beginCooldown(); + pending.resolve(new Blob(['pixels'])); + + await expect(capture).resolves.toEqual({ status: 'stale' }); + await cooldown; + engine.lifecycle.dispose(); + }); + + it('promptly aborts raster snapshot cache preparation without waiting for a stalled decode', async () => { + const { requests, resolver } = createAbortableImageResolver(); + const { store } = createReactiveStore(makeDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + const controller = new AbortController(); + const documentSnapshot = engine.document.captureSnapshot(); + const capture = engine.exports.captureRasterSnapshot(documentSnapshot!, ['a'], { signal: controller.signal }); + const settled = vi.fn(); + void capture.then(settled); + await drainMicrotasksUntil(() => requests.has('a')); + + controller.abort(new DOMException('invoke cancelled', 'AbortError')); + await drainMicrotasksUntil(() => settled.mock.calls.length > 0); + + expect(requests.get('a')?.signal?.aborted).toBe(true); + expect(settled).toHaveBeenCalledWith({ status: 'aborted' }); + engine.lifecycle.dispose(); + }); + + it('releases snapshot reservations and pins promptly after abort so a large retry can proceed', async () => { + const imageSize = 5_300; + const largeLayer: CanvasRasterLayerContractV2 = { + blendMode: 'normal', + id: 'large', + isEnabled: true, + isLocked: false, + name: 'large', + opacity: 1, + source: { + image: { height: imageSize, imageName: 'large', width: imageSize }, + type: 'image', + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }; + const document: CanvasDocumentContractV2 = { + ...makeDoc(), + height: imageSize, + layers: [largeLayer], + width: imageSize, + }; + const { requests, resolver } = createAbortableImageResolver(); + const { store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + const controller = new AbortController(); + const firstSnapshot = engine.document.captureSnapshot(); + const firstCapture = engine.exports.captureRasterSnapshot(firstSnapshot!, ['large'], { + signal: controller.signal, + }); + await drainMicrotasksUntil(() => requests.has('large')); + + controller.abort(new DOMException('invoke cancelled', 'AbortError')); + await expect(firstCapture).resolves.toEqual({ status: 'aborted' }); + + requests.delete('large'); + const retrySnapshot = engine.document.captureSnapshot(); + const retryCapture = engine.exports.captureRasterSnapshot(retrySnapshot!, ['large']); + await drainMicrotasksUntil(() => requests.has('large')); + requests.get('large')?.deferred.resolve(new Blob(['retry pixels'])); + + const retry = await retryCapture; + expect(retry.status).toBe('ok'); + if (retry.status === 'ok') { + retry.snapshot.release(); + } + engine.lifecycle.dispose(); + }); + + it('returns typed not-ready when asynchronous raster cache preparation fails', async () => { + const { store } = createReactiveStore(makeDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.reject(new Error('decode unavailable')), + projectId: 'p1', + reportError: vi.fn(), + store, + }); + const documentSnapshot = engine.document.captureSnapshot(); + + await expect(engine.exports.captureRasterSnapshot(documentSnapshot!, ['a'])).resolves.toEqual({ + status: 'not-ready', + }); + engine.lifecycle.dispose(); + }); + + it('returns caller-owned detached layer surfaces with an idempotent release', async () => { + const { engine } = createEngine(); + const documentSnapshot = engine.document.captureSnapshot(); + + const result = await engine.exports.captureRasterSnapshot(documentSnapshot!, ['a', 'a']); + + expect(result.status).toBe('ok'); + if (result.status !== 'ok') { + throw new Error('raster snapshot was not captured'); + } + expect([...result.snapshot.layerSurfaces.keys()]).toEqual(['a']); + expect(result.snapshot.layerSurfaces.get('a')?.surface).toBeDefined(); + result.snapshot.release(); + result.snapshot.release(); + expect(result.snapshot.layerSurfaces.size).toBe(0); + engine.lifecycle.dispose(); + }); + + it('captures valid pixels while identifying a genuinely empty paint layer for callers to skip', async () => { + const blank = { ...rasterLayer('blank'), source: { bitmap: null, type: 'paint' } as const }; + const { store } = createFakeStore({ ...makeDoc(), layers: [rasterLayer('valid'), blank] }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob(['valid'])), + projectId: 'p1', + store, + }); + const documentSnapshot = engine.document.captureSnapshot(); + + const result = await engine.exports.captureRasterSnapshot(documentSnapshot!, ['valid', 'blank']); + + expect(result.status).toBe('ok'); + if (result.status !== 'ok') { + throw new Error('Expected mixed raster snapshot'); + } + expect([...result.snapshot.layerSurfaces.keys()]).toEqual(['valid']); + expect([...result.snapshot.emptyLayerIds]).toEqual(['blank']); + result.snapshot.release(); + engine.lifecycle.dispose(); + }); + + it('returns a successful empty raster snapshot for a blank-only document', async () => { + const blank = { ...rasterLayer('blank'), source: { bitmap: null, type: 'paint' } as const }; + const { store } = createFakeStore({ ...makeDoc(), layers: [blank] }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const documentSnapshot = engine.document.captureSnapshot(); + + const result = await engine.exports.captureRasterSnapshot(documentSnapshot!, ['blank']); + + expect(result.status).toBe('ok'); + if (result.status !== 'ok') { + throw new Error('Expected empty raster snapshot'); + } + expect(result.snapshot.layerSurfaces.size).toBe(0); + expect([...result.snapshot.emptyLayerIds]).toEqual(['blank']); + result.snapshot.release(); + engine.lifecycle.dispose(); + }); + + it('keeps caller-owned raster snapshot surfaces alive across cooldown', async () => { + const { engine } = createEngine(); + const documentSnapshot = engine.document.captureSnapshot(); + const result = await engine.exports.captureRasterSnapshot(documentSnapshot!, ['a']); + expect(result.status).toBe('ok'); + if (result.status !== 'ok') { + throw new Error('raster snapshot was not captured'); + } + + await engine.lifecycle.beginCooldown(); + + expect(result.snapshot.layerSurfaces.size).toBe(1); + result.snapshot.release(); + expect(result.snapshot.layerSurfaces.size).toBe(0); + engine.lifecycle.dispose(); + }); + + it('refuses a raster snapshot using the actual live surface bytes before cloning', async () => { + const { engine } = createEngine(); + const live = await engine.exports.exportLayerPixels('a'); + expect(live.status).toBe('ok'); + if (live.status !== 'ok') { + throw new Error('layer cache was not rasterized'); + } + // Model a live paint cache that has grown beyond its persisted source bounds. + // The stub resize records dimensions without allocating a real 276 MiB buffer. + // One live surface fits the 512 MiB limit; cloning a second one does not. + live.surface.resize(8_500, 8_500); + const documentSnapshot = engine.document.captureSnapshot(); + + await expect(engine.exports.captureRasterSnapshot(documentSnapshot!, ['a'])).resolves.toEqual({ + status: 'over-budget', + }); + + engine.lifecycle.dispose(); + }); + + it('releases detached raster snapshot surfaces when the engine is disposed', async () => { + const { engine } = createEngine(); + const documentSnapshot = engine.document.captureSnapshot(); + const result = await engine.exports.captureRasterSnapshot(documentSnapshot!, ['a']); + expect(result.status).toBe('ok'); + if (result.status !== 'ok') { + throw new Error('raster snapshot was not captured'); + } + + engine.lifecycle.dispose(); + + expect(result.snapshot.layerSurfaces.size).toBe(0); + }); + it('mirrors the reducer-owned document on creation', () => { + const { doc, engine } = createEngine(); + expect(engine.document.getDocument()).toBe(doc); + engine.lifecycle.dispose(); + }); + + it('exposes an initial viewport and stores', () => { + const { engine } = createEngine(); + expect(engine.viewport.getViewport().getZoom()).toBe(1); + expect(engine.stores.activeTool.get()).toBe('view'); + expect(engine.stores.viewportReady.get()).toBe(false); + expect(engine.surface.attach).toBe(engine.surface.attach); + expect(engine.viewport.getViewport).toBe(engine.viewport.getViewport); + expect(engine.tools.setTool).toBeTypeOf('function'); + expect(engine.history.undo).toBe(engine.history.undo); + expect(engine.lifecycle.beginCooldown).toBe(engine.lifecycle.beginCooldown); + expect(engine.edits.tryAcquire).toBeTypeOf('function'); + engine.lifecycle.dispose(); + }); + + it('exposes grouped capabilities without the deprecated flat facade', () => { + const { engine } = createEngine(); + const deprecatedFlatMethods = [ + 'attach', + 'beginCooldown', + 'commitStructural', + 'dispose', + 'exportLayerPixels', + 'fitToView', + 'getDocument', + 'processFilterOperation', + 'setTool', + 'startSelectObject', + 'undo', + ] as const; + + for (const method of deprecatedFlatMethods) { + expect(method in engine).toBe(false); + } + expect(engine.surface.attach).toBeTypeOf('function'); + expect(engine.layers.commitStructural).toBeTypeOf('function'); + expect(getCanvasOperations(engine).startSelectObject).toBeTypeOf('function'); + expect(engine.exports.exportLayerPixels).toBeTypeOf('function'); + engine.lifecycle.dispose(); + }); + + it('invalidates edit leases across cooldown, reactivation, and disposal', async () => { + const { engine } = createEngine(); + const first = engine.edits.tryAcquire({ kind: 'filter', layerId: 'a' }); + expect(first?.isCurrent()).toBe(true); + + await engine.lifecycle.beginCooldown(); + expect(first?.signal.aborted).toBe(true); + expect(first?.isCurrent()).toBe(false); + expect(engine.edits.tryAcquire({ kind: 'filter', layerId: 'a' })).toBeNull(); + + engine.lifecycle.activate(); + const second = engine.edits.tryAcquire({ kind: 'select-object', layerId: 'a' }); + expect(second?.isCurrent()).toBe(true); + engine.lifecycle.dispose(); + expect(second?.signal.aborted).toBe(true); + expect(second?.isCurrent()).toBe(false); + }); + + it('retains base pixels when cooldown persistence fails', async () => { + const doc = makeDoc(); + const { store } = createFakeStore(doc); + const bitmapStore = createSpyBitmapStore(); + vi.mocked(bitmapStore.flushPendingUploads).mockRejectedValueOnce(new Error('offline')); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const before = await engine.exports.exportLayerPixels('a'); + expect(before.status).toBe('ok'); + + await expect(engine.lifecycle.beginCooldown()).resolves.toBe('dirty'); + const retained = await engine.exports.exportLayerPixels('a'); + expect(retained.status).toBe('ok'); + if (before.status === 'ok' && retained.status === 'ok') { + expect(retained.surface).toBe(before.surface); + } + engine.lifecycle.dispose(); + }); + + it('retries persistence after a dirty cooldown result', async () => { + const doc = makeDoc(); + const { store } = createFakeStore(doc); + const bitmapStore = createSpyBitmapStore(); + vi.mocked(bitmapStore.flushPendingUploads) + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(undefined); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + await expect(engine.lifecycle.beginCooldown()).resolves.toBe('dirty'); + await expect(engine.lifecycle.beginCooldown()).resolves.toBe('cooled'); + + expect(bitmapStore.flushPendingUploads).toHaveBeenCalledTimes(2); + engine.lifecycle.dispose(); + }); + + it('keeps an in-flight invocation reservation accounted through cooldown', async () => { + const { engine } = createEngine(); + const deps = engine.exports.getCompositeExecutorDeps(); + const reservation = deps.reserve?.(300 * 1024 * 1024); + expect(reservation?.status).toBe('ok'); + + await engine.lifecycle.beginCooldown(); + + const competing = deps.reserve?.(300 * 1024 * 1024); + expect(competing?.status).toBe('over-budget'); + + if (reservation?.status === 'ok') { + reservation.lease.release(); + } + engine.lifecycle.dispose(); + }); + + it('does not release reconstructed pixels when reactivated during a cooldown flush', async () => { + const doc = makeDoc(); + const { store } = createFakeStore(doc); + const bitmapStore = createSpyBitmapStore(); + const flush = createDeferred(); + vi.mocked(bitmapStore.flushPendingUploads).mockReturnValueOnce(flush.promise); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const before = await engine.exports.exportLayerPixels('a'); + const cooling = engine.lifecycle.beginCooldown(); + engine.lifecycle.activate(); + flush.resolve(); + await cooling; + + expect(engine.lifecycle.getLifecycleState()).toBe('active'); + const retained = await engine.exports.exportLayerPixels('a'); + if (before.status === 'ok' && retained.status === 'ok') { + expect(retained.surface).toBe(before.surface); + } + engine.lifecycle.dispose(); + }); + + it('exportLayerPixels rasterizes a visible layer and returns its cache surface plus content rect', async () => { + const { engine } = createEngine(); + + const result = await engine.exports.exportLayerPixels('a'); + + expect(result.status).toBe('ok'); + if (result.status === 'ok') { + expect(result.rect).toEqual({ height: 10, width: 10, x: 0, y: 0 }); + expect(result.surface.width).toBe(10); + expect(result.surface.height).toBe(10); + expect((result.surface as StubRasterSurface).callLog.some((entry) => entry.op === 'drawImage')).toBe(true); + } + engine.lifecycle.dispose(); + }); + + it('exportLayerPixels refuses hidden layers unless includeDisabled is set', async () => { + const hidden = { ...rasterLayer('hidden'), isEnabled: false }; + const { store } = createFakeStore({ ...makeDoc(), layers: [hidden] }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + expect(await engine.exports.exportLayerPixels('hidden')).toEqual({ status: 'disabled' }); + expect((await engine.exports.exportLayerPixels('hidden', { includeDisabled: true })).status).toBe('ok'); + engine.lifecycle.dispose(); + }); + + it('exportLayerPixels can bake raster adjustments in layer-local space without baking presentation props', async () => { + const layer: CanvasRasterLayerContractV2 = { + ...(rasterLayer('a') as CanvasRasterLayerContractV2), + adjustments: { brightness: 0.25, contrast: 0, saturation: 0 }, + blendMode: 'multiply', + opacity: 0.4, + source: { + bitmap: { height: 10, imageName: 'local-paint', width: 10 }, + offset: { x: -4, y: 7 }, + type: 'paint', + }, + transform: { rotation: 0, scaleX: 2, scaleY: 3, x: 5, y: 6 }, + }; + const { store } = createFakeStore({ ...makeDoc(), layers: [layer] }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + const raw = await engine.exports.exportLayerPixels('a'); + const adjusted = await engine.exports.exportLayerPixels('a', { applyAdjustments: true }); + + expect(raw.status).toBe('ok'); + expect(adjusted.status).toBe('ok'); + if (raw.status === 'ok' && adjusted.status === 'ok') { + expect(adjusted.rect).toEqual({ height: 10, width: 10, x: -4, y: 7 }); + expect(adjusted.surface).not.toBe(raw.surface); + expect(adjusted.surface).toMatchObject({ height: 10, width: 10 }); + expect(engine.exports.isLayerExportGuardCurrent(adjusted.guard)).toBe(true); + expect((raw.surface as StubRasterSurface).callLog.some((entry) => entry.op === 'putImageData')).toBe(false); + expect((adjusted.surface as StubRasterSurface).callLog).toEqual( + expect.arrayContaining([ + expect.objectContaining({ op: 'drawImage' }), + { args: [0, 0, 10, 10], op: 'getImageData' }, + expect.objectContaining({ op: 'putImageData' }), + ]) + ); + expect( + (adjusted.surface as StubRasterSurface).callLog.some( + (entry) => + entry.op === 'set' && (entry.args[0] === 'globalAlpha' || entry.args[0] === 'globalCompositeOperation') + ) + ).toBe(false); + } + engine.lifecycle.dispose(); + }); + + it('exportLayerPixels shares the scheduled rasterization already running for the same source', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const pendingResolve = createDeferred(); + const imageResolver = vi.fn(() => pendingResolve.promise); + const { store } = createReactiveStore(makeDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver, + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + + const exported = engine.exports.exportLayerPixels('a'); + expect(imageResolver).toHaveBeenCalledTimes(1); + + pendingResolve.resolve(new Blob()); + expect((await exported).status).toBe('ok'); + engine.lifecycle.dispose(); + }); + + it('does not publish an older rasterization after a newer source wins', async () => { + const first = createDeferred(); + const second = createDeferred(); + const blobA = new Blob(['A']); + const blobB = new Blob(['B']); + const bitmapA = recordingBitmap('A'); + const bitmapB = recordingBitmap('B'); + const backend = createRecordingRasterBackend(); + backend.createImageBitmap = vi.fn((blob) => Promise.resolve(blob === blobA ? bitmapA : bitmapB)); + const imageResolver = vi.fn((imageName: string) => (imageName === 'A' ? first.promise : second.promise)); + const document = { ...makeDoc(), layers: [rasterLayer('L', { imageName: 'A' })] }; + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ backend, imageResolver, projectId: 'p1', store }); + + const exportA = engine.exports.exportLayerPixels('L'); + setDocument({ ...document, layers: [rasterLayer('L', { imageName: 'B' })] }); + const exportB = engine.exports.exportLayerPixels('L'); + + second.resolve(blobB); + const resultB = await exportB; + expect(resultB.status).toBe('ok'); + if (resultB.status !== 'ok') { + throw new Error('newer rasterization did not publish'); + } + const publicationAfterB = backend.drawSourcesFor(resultB.surface as StubRasterSurface); + + first.resolve(blobA); + expect(await exportA).toEqual({ status: 'not-ready' }); + expect(backend.drawSourcesFor(resultB.surface as StubRasterSurface)).toEqual(publicationAfterB); + engine.lifecycle.dispose(); + }); + + it('does not publish a rasterization from a replaced document that reuses the layer id', async () => { + const first = createDeferred(); + const second = createDeferred(); + const blobA = new Blob(['document-A']); + const blobB = new Blob(['document-B']); + const backend = createRecordingRasterBackend(); + backend.createImageBitmap = vi.fn((blob) => + Promise.resolve(blob === blobA ? recordingBitmap('document-A') : recordingBitmap('document-B')) + ); + const imageResolver = vi.fn((imageName: string) => (imageName === 'A' ? first.promise : second.promise)); + const document = { ...makeDoc(), layers: [rasterLayer('L', { imageName: 'A' })] }; + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ backend, imageResolver, projectId: 'p1', store }); + + const oldExport = engine.exports.exportLayerPixels('L'); + setDocument({ ...document, layers: [rasterLayer('L', { imageName: 'B' })] }, 1); + const newExport = engine.exports.exportLayerPixels('L'); + + second.resolve(blobB); + const newResult = await newExport; + expect(newResult.status).toBe('ok'); + if (newResult.status !== 'ok') { + throw new Error('replacement rasterization did not publish'); + } + const publicationAfterReplacement = backend.drawSourcesFor(newResult.surface as StubRasterSurface); + + first.resolve(blobA); + expect(await oldExport).toEqual({ status: 'not-ready' }); + expect(backend.drawSourcesFor(newResult.surface as StubRasterSurface)).toEqual(publicationAfterReplacement); + engine.lifecycle.dispose(); + }); + + it('shares one rasterization between concurrent exports of the same version', async () => { + const blob = new Blob(['shared']); + const imageResolver = vi.fn(() => Promise.resolve(blob)); + const { store } = createFakeStore({ ...makeDoc(), layers: [rasterLayer('L')] }); + const engine = createCanvasEngine({ + backend: createRecordingRasterBackend(), + imageResolver, + projectId: 'p1', + store, + }); + + const [a, b] = await Promise.all([engine.exports.exportLayerPixels('L'), engine.exports.exportLayerPixels('L')]); + + expect(imageResolver).toHaveBeenCalledTimes(1); + expect(a.status).toBe('ok'); + expect(b.status).toBe('ok'); + engine.lifecycle.dispose(); + }); + + it('does not return a default export when the layer becomes disabled during rasterization', async () => { + const pending = createDeferred(); + const layer = rasterLayer('L'); + const document = { ...makeDoc(), layers: [layer] }; + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => pending.promise, + projectId: 'p1', + store, + }); + + const exported = engine.exports.exportLayerPixels('L'); + setDocument({ ...document, layers: [{ ...layer, isEnabled: false }] }); + pending.resolve(new Blob()); + + expect(await exported).toEqual({ status: 'disabled' }); + engine.lifecycle.dispose(); + }); + + it('publishes an empty paint cache without drawing a zero-sized scratch canvas', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const empty = { ...rasterLayer('empty'), source: { bitmap: null, type: 'paint' } as const }; + const { store } = createReactiveStore({ ...makeDoc(), layers: [empty] }); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const engine = createCanvasEngine({ + backend: { + ...base, + createSurface: (width, height) => { + const surface = base.createSurface(width, height); + surfaces.push(surface); + return surface; + }, + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + + const zeroSizedDraws = surfaces.flatMap((surface) => + surface.callLog.filter((entry) => { + if (entry.op !== 'drawImage') { + return false; + } + const source = entry.args[0] as { height?: number; width?: number }; + return source.width === 0 || source.height === 0; + }) + ); + expect(zeroSizedDraws).toEqual([]); + expect(await engine.exports.exportLayerPixels('empty')).toEqual({ status: 'empty' }); + engine.lifecycle.dispose(); + }); + + it('retries a same-version rasterization after a one-time synchronous rasterizer failure', async () => { + const shape: CanvasLayerSourceContract = { + fill: '#fff', + height: 10, + kind: 'rect', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 10, + }; + const layer = { ...rasterLayer('shape'), source: shape }; + const { store } = createFakeStore({ ...makeDoc(), layers: [layer] }); + const base = createTestStubRasterBackend(); + let shouldThrow = true; + const engine = createCanvasEngine({ + backend: { + ...base, + createSurface: (width, height) => { + const surface = base.createSurface(width, height); + const ctx = new Proxy(surface.ctx, { + get(target, property, receiver) { + if (property === 'fill') { + return (...args: unknown[]) => { + if (shouldThrow) { + shouldThrow = false; + throw new Error('one-time fill failure'); + } + return Reflect.apply(target.fill, target, args); + }; + } + return Reflect.get(target, property, receiver); + }, + }); + Object.defineProperty(surface, 'ctx', { value: ctx }); + return surface; + }, + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + expect(await engine.exports.exportLayerPixels('shape')).toEqual({ status: 'not-ready' }); + expect((await engine.exports.exportLayerPixels('shape')).status).toBe('ok'); + engine.lifecycle.dispose(); + }); + + it('exportBakedLayerPixels draws the cache through the layer transform into a document-space surface', async () => { + const layer = { + ...rasterLayer('a'), + transform: { rotation: 0, scaleX: 2, scaleY: 3, x: 5, y: 6 }, + }; + const { store } = createFakeStore({ ...makeDoc(), layers: [layer] }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + const result = await engine.exports.exportBakedLayerPixels('a'); + + expect(result.status).toBe('ok'); + if (result.status === 'ok') { + expect(result.rect).toEqual({ height: 30, width: 20, x: 5, y: 6 }); + expect(result.surface.width).toBe(20); + expect(result.surface.height).toBe(30); + expect((result.surface as StubRasterSurface).callLog).toEqual( + expect.arrayContaining([ + { args: [1, 0, 0, 1, 0, 0], op: 'setTransform' }, + { args: [2, 0, 0, 3, 0, 0], op: 'setTransform' }, + expect.objectContaining({ op: 'drawImage' }), + ]) + ); + } + engine.lifecycle.dispose(); + }); + + it('exportBakedLayerPixels bakes raster adjustments into the exported surface only', async () => { + const layer = { + ...rasterLayer('a'), + adjustments: { brightness: 0.25, contrast: 0, saturation: 0 }, + }; + const { store } = createFakeStore({ ...makeDoc(), layers: [layer] }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + const raw = await engine.exports.exportLayerPixels('a'); + const baked = await engine.exports.exportBakedLayerPixels('a'); + + expect(raw.status).toBe('ok'); + expect(baked.status).toBe('ok'); + if (raw.status === 'ok' && baked.status === 'ok') { + expect(baked.surface).not.toBe(raw.surface); + expect((raw.surface as StubRasterSurface).callLog.some((entry) => entry.op === 'putImageData')).toBe(false); + expect((baked.surface as StubRasterSurface).callLog).toEqual( + expect.arrayContaining([ + { args: [0, 0, 10, 10], op: 'getImageData' }, + expect.objectContaining({ op: 'putImageData' }), + ]) + ); + } + engine.lifecycle.dispose(); + }); + + it('exportBakedLayerPixels applies adjustments exactly once when explicitly requested', async () => { + const layer = { + ...rasterLayer('a'), + adjustments: { brightness: 0.25, contrast: 0, saturation: 0 }, + }; + const { store } = createFakeStore({ ...makeDoc(), layers: [layer] }); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const engine = createCanvasEngine({ + backend: { + ...base, + createSurface: (width, height) => { + const surface = base.createSurface(width, height); + surfaces.push(surface); + return surface; + }, + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + expect((await engine.exports.exportBakedLayerPixels('a', { applyAdjustments: true })).status).toBe('ok'); + + expect(surfaces.flatMap((surface) => surface.callLog).filter((entry) => entry.op === 'getImageData')).toHaveLength( + 1 + ); + engine.lifecycle.dispose(); + }); + + it('exportBakedLayerBlob encodes the baked layer surface as PNG', async () => { + const { engine } = createEngine(); + + const result = await engine.exports.exportBakedLayerBlob('a'); + + expect(result.status).toBe('ok'); + if (result.status === 'ok') { + expect(result.rect).toEqual({ height: 10, width: 10, x: 0, y: 0 }); + expect(result.blob.type).toBe('image/png'); + expect(await result.blob.text()).toBe('stub-surface-10x10'); + } + engine.lifecycle.dispose(); + }); + + it('returns a current LayerExportGuard from local, baked-pixel, and baked-blob exports', async () => { + const { doc, engine } = createEngine(); + + const local = await engine.exports.exportLayerPixels('a'); + const baked = await engine.exports.exportBakedLayerPixels('a'); + const blob = await engine.exports.exportBakedLayerBlob('a'); + + expect(local.status).toBe('ok'); + expect(baked.status).toBe('ok'); + expect(blob.status).toBe('ok'); + if (local.status !== 'ok' || baked.status !== 'ok' || blob.status !== 'ok') { + throw new Error('expected successful exports'); + } + for (const result of [local, baked, blob]) { + expect(result.guard).toMatchObject({ layer: doc.layers[0], layerId: 'a', projectId: 'p1' }); + expect(engine.exports.isLayerExportGuardCurrent(result.guard)).toBe(true); + } + engine.lifecycle.dispose(); + }); + + it('captures the current layer export guard synchronously after its cache is ready', async () => { + const { engine } = createEngine(); + await engine.exports.exportLayerPixels('a'); + + const guard = engine.exports.captureLayerExportGuard('a'); + + expect(guard).not.toBeNull(); + expect(guard).toMatchObject({ layerId: 'a', projectId: 'p1' }); + expect(engine.exports.isLayerExportGuardCurrent(guard!)).toBe(true); + expect(engine.exports.captureLayerExportGuard('missing')).toBeNull(); + engine.lifecycle.dispose(); + }); + + it('invalidates a LayerExportGuard when the immutable layer contract changes', async () => { + const document = makeDoc(); + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const exported = await engine.exports.exportLayerPixels('a'); + expect(exported.status).toBe('ok'); + if (exported.status !== 'ok') { + throw new Error('expected successful export'); + } + const cleanupPreview = vi.fn(); + getCanvasOperations(engine).controller.start({ + cleanupPreview, + guard: exported.guard, + identity: { kind: 'filter', layerId: 'a', projectId: 'p1' }, + }); + + setDocument({ ...document, layers: [{ ...document.layers[0]!, opacity: 0.5 }] }); + + expect(engine.exports.isLayerExportGuardCurrent(exported.guard)).toBe(false); + expect(cleanupPreview).toHaveBeenCalledOnce(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + engine.lifecycle.dispose(); + }); + + it('invalidates a canvas operation when its layer source changes or its layer is removed', async () => { + for (const nextLayers of [ + [ + { + ...makeDoc().layers[0]!, + source: { image: { height: 10, imageName: 'replacement', width: 10 }, type: 'image' as const }, + }, + ], + [], + ]) { + const document = makeDoc(); + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const exported = await engine.exports.exportLayerPixels('a'); + if (exported.status !== 'ok') { + throw new Error('expected successful export'); + } + const cleanupPreview = vi.fn(); + getCanvasOperations(engine).controller.start({ + cleanupPreview, + guard: exported.guard, + identity: { kind: 'filter', layerId: 'a', projectId: 'p1' }, + }); + + setDocument({ ...document, layers: nextLayers }); + + expect(cleanupPreview).toHaveBeenCalledOnce(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + engine.lifecycle.dispose(); + } + }); + + it('invalidates a LayerExportGuard when the document is replaced with the same layer object', async () => { + const document = makeDoc(); + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const exported = await engine.exports.exportLayerPixels('a'); + expect(exported.status).toBe('ok'); + if (exported.status !== 'ok') { + throw new Error('expected successful export'); + } + const cleanupPreview = vi.fn(); + getCanvasOperations(engine).controller.start({ + cleanupPreview, + guard: exported.guard, + identity: { kind: 'filter', layerId: 'a', projectId: 'p1' }, + }); + + setDocument({ ...document, layers: document.layers }, 1); + + expect(engine.exports.isLayerExportGuardCurrent(exported.guard)).toBe(false); + expect(cleanupPreview).toHaveBeenCalledOnce(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + engine.lifecycle.dispose(); + }); + + it('keeps a project-bound LayerExportGuard current when another project becomes active', async () => { + const document = makeDoc(); + const { setActiveProjectId, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const exported = await engine.exports.exportLayerPixels('a'); + expect(exported.status).toBe('ok'); + if (exported.status !== 'ok') { + throw new Error('expected successful export'); + } + const cleanupPreview = vi.fn(); + getCanvasOperations(engine).controller.start({ + cleanupPreview, + guard: exported.guard, + identity: { kind: 'filter', layerId: 'a', projectId: 'p1' }, + }); + + setActiveProjectId('p2'); + + expect(engine.exports.isLayerExportGuardCurrent(exported.guard)).toBe(true); + expect(cleanupPreview).not.toHaveBeenCalled(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toMatchObject({ status: 'active' }); + engine.lifecycle.dispose(); + }); + + it('disposes the active canvas operation with the engine', async () => { + const { engine } = createEngine(); + const exported = await engine.exports.exportLayerPixels('a'); + if (exported.status !== 'ok') { + throw new Error('expected successful export'); + } + const cleanupPreview = vi.fn(); + getCanvasOperations(engine).controller.start({ + cleanupPreview, + guard: exported.guard, + identity: { kind: 'filter', layerId: 'a', projectId: 'p1' }, + }); + + engine.lifecycle.dispose(); + + expect(cleanupPreview).toHaveBeenCalledOnce(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + expect( + getCanvasOperations(engine).controller.start({ + cleanupPreview, + guard: exported.guard, + identity: { kind: 'filter', layerId: 'a', projectId: 'p1' }, + }) + ).toBeNull(); + }); + + it('does not return an encoded layer blob after its export guard becomes stale', async () => { + const encoded = createDeferred(); + const base = createTestStubRasterBackend(); + const encodeSurface = vi.fn(() => encoded.promise); + const document = makeDoc(); + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: { ...base, encodeSurface }, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + const pending = engine.exports.exportBakedLayerBlob('a'); + await vi.waitFor(() => expect(encodeSurface).toHaveBeenCalledOnce()); + setDocument({ ...document, layers: [{ ...document.layers[0]!, opacity: 0.5 }] }); + encoded.resolve(new Blob(['stale'], { type: 'image/png' })); + + expect(await pending).toEqual({ status: 'not-ready' }); + engine.lifecycle.dispose(); + }); + + it('invalidates a LayerExportGuard when live cache pixels are edited', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + const document = paintDoc(); + const layer = document.layers[0]; + if (!layer || layer.type !== 'raster') { + throw new Error('expected paint layer'); + } + const persisted: CanvasDocumentContractV2 = { + ...document, + layers: [ + { + ...layer, + source: { bitmap: { height: 20, imageName: 'paint-pixels', width: 20 }, type: 'paint' }, + }, + ], + }; + const { projectId, store } = createReducerBackedStore(persisted); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + const exported = await engine.exports.exportLayerPixels(layer.id); + expect(exported.status).toBe('ok'); + if (exported.status !== 'ok') { + throw new Error('expected successful export'); + } + + engine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(5, 5)); + overlay.fire('pointermove', pointerAt(10, 10)); + overlay.fire('pointerup', pointerAt(10, 10, { buttons: 0 })); + + expect(engine.exports.isLayerExportGuardCurrent(exported.guard)).toBe(false); + engine.lifecycle.dispose(); + }); + + it('cropLayerToBbox crops only the bbox overlap and restores exact contracts/cache snapshots on undo/redo', async () => { + const layer = { + ...rasterLayer('a'), + adjustments: { brightness: 0.2, contrast: -0.1, saturation: 0.3 }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 5, y: 5 }, + }; + const document = { ...makeDoc(), bbox: { height: 8, width: 10, x: 8, y: 2 }, layers: [layer] }; + const { projectId, store } = createReducerBackedStore(document); + const backend = createRecordingRasterBackend(); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const beforeContract = structuredClone(engine.document.getDocument()!.layers[0]!); + const beforeExport = await engine.exports.exportLayerPixels('a'); + expect(beforeExport.status).toBe('ok'); + if (beforeExport.status !== 'ok') { + throw new Error('expected original cache pixels'); + } + const thumbnailListener = vi.fn(); + engine.stores.thumbnailVersion.subscribeKey('a', thumbnailListener); + bitmapStore.markLayerDirty.mockClear(); + + expect(await engine.layers.cropLayerToBbox('a')).toEqual({ status: 'cropped' }); + + const afterContract = engine.document.getDocument()!.layers[0]!; + expect(afterContract).toEqual({ + blendMode: 'normal', + id: 'a', + isEnabled: true, + isLocked: false, + name: 'a', + opacity: 1, + source: { bitmap: null, offset: { x: 8, y: 5 }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }); + expect('adjustments' in afterContract).toBe(false); + expect(bitmapStore.markLayerDirty).toHaveBeenCalledTimes(1); + expect(thumbnailListener).toHaveBeenCalledTimes(1); + const forwardExport = await engine.exports.exportLayerPixels('a'); + expect(forwardExport.status).toBe('ok'); + if (forwardExport.status !== 'ok') { + throw new Error('expected cropped cache pixels'); + } + expect(forwardExport.rect).toEqual({ height: 5, width: 7, x: 8, y: 5 }); + const forwardSources = backend.drawSourcesFor(forwardExport.surface as StubRasterSurface); + + engine.history.undo(); + expect(engine.document.getDocument()!.layers[0]).toEqual(beforeContract); + expect(bitmapStore.markLayerDirty).toHaveBeenCalledTimes(2); + expect(thumbnailListener).toHaveBeenCalledTimes(2); + const undoExport = await engine.exports.exportLayerPixels('a'); + expect(undoExport.status).toBe('ok'); + if (undoExport.status !== 'ok') { + throw new Error('expected restored cache pixels'); + } + const undoSources = backend.drawSourcesFor(undoExport.surface as StubRasterSurface); + const beforeSnapshot = backend.surfaceById(undoSources.at(-1)!); + expect(beforeSnapshot).toBeDefined(); + expect(backend.drawSourcesFor(beforeSnapshot!)).toContain( + backend.surfaceId(beforeExport.surface as StubRasterSurface) + ); + + engine.history.redo(); + expect(engine.document.getDocument()!.layers[0]).toEqual(afterContract); + expect(bitmapStore.markLayerDirty).toHaveBeenCalledTimes(3); + expect(thumbnailListener).toHaveBeenCalledTimes(3); + const redoExport = await engine.exports.exportLayerPixels('a'); + expect(redoExport.status).toBe('ok'); + if (redoExport.status !== 'ok') { + throw new Error('expected redone cache pixels'); + } + expect(backend.drawSourcesFor(redoExport.surface as StubRasterSurface)).toEqual(forwardSources); + engine.lifecycle.dispose(); + }); + + it('cropLayerToBbox preserves control adapter settings while removing the baked filter', async () => { + const control: CanvasLayerContract = { + adapter: { + beginEndStepPct: [0.1, 0.9], + controlMode: 'more_control', + kind: 'controlnet', + model: 'm', + weight: 0.7, + }, + blendMode: 'screen', + filter: { settings: { low: 10 }, type: 'canny' }, + id: 'control', + isEnabled: true, + isLocked: false, + name: 'Control', + opacity: 0.6, + source: { image: { height: 10, imageName: 'control', width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: true, + }; + const document = { ...makeDoc(), bbox: { height: 7, width: 6, x: 2, y: 3 }, layers: [control] }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + + expect(await engine.layers.cropLayerToBbox('control')).toEqual({ status: 'cropped' }); + const cropped = engine.document.getDocument()!.layers[0]; + expect(cropped).toMatchObject({ + adapter: control.adapter, + source: { bitmap: null, offset: { x: 2, y: 3 }, type: 'paint' }, + type: 'control', + withTransparencyEffect: true, + }); + expect(cropped && 'filter' in cropped).toBe(false); + engine.lifecycle.dispose(); + }); + + it('cropLayerToBbox preserves mask fill/noise/denoise configuration while replacing pixels and offset', async () => { + const layer: CanvasInpaintMaskLayerContract = { + ...maskLayer('mask'), + denoiseLimit: 0.45, + mask: { + bitmap: { height: 10, imageName: 'mask-img', width: 10 }, + fill: { color: '#123456', style: 'crosshatch' }, + offset: { x: 0, y: 0 }, + }, + noiseLevel: 0.25, + }; + const document = { ...makeDoc(), bbox: { height: 7, width: 6, x: 2, y: 3 }, layers: [layer] }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + + expect(await engine.layers.cropLayerToBbox('mask')).toEqual({ status: 'cropped' }); + expect(engine.document.getDocument()!.layers[0]).toMatchObject({ + denoiseLimit: 0.45, + mask: { bitmap: null, fill: layer.mask.fill, offset: { x: 2, y: 3 } }, + noiseLevel: 0.25, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', + }); + engine.lifecycle.dispose(); + }); + + it('cropLayerToBbox preserves regional prompts and reference images while replacing mask pixels', async () => { + const layer: CanvasLayerContract = { + autoNegative: false, + blendMode: 'normal', + id: 'region', + isEnabled: true, + isLocked: false, + mask: { + bitmap: { height: 10, imageName: 'region-mask', width: 10 }, + fill: { color: '#abcdef', style: 'vertical' }, + }, + name: 'Region', + negativePrompt: 'negative', + opacity: 0.8, + positivePrompt: 'positive', + referenceImages: [{ config: { image: null, type: 'flux2_reference_image' }, id: 'reference', isEnabled: true }], + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'regional_guidance', + }; + const document = { ...makeDoc(), bbox: { height: 7, width: 6, x: 2, y: 3 }, layers: [layer] }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + + expect(await engine.layers.cropLayerToBbox('region')).toEqual({ status: 'cropped' }); + expect(engine.document.getDocument()!.layers[0]).toMatchObject({ + autoNegative: false, + mask: { bitmap: null, fill: layer.mask.fill, offset: { x: 2, y: 3 } }, + negativePrompt: 'negative', + positivePrompt: 'positive', + referenceImages: layer.referenceImages, + type: 'regional_guidance', + }); + engine.lifecycle.dispose(); + }); + + it('cropLayerToBbox returns empty without mutation when the layer and bbox do not overlap', async () => { + const document = { ...makeDoc(), bbox: { height: 5, width: 5, x: 50, y: 50 } }; + const { projectId, store } = createReducerBackedStore(document); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const before = engine.document.getDocument(); + + expect(await engine.layers.cropLayerToBbox('a')).toEqual({ status: 'empty' }); + expect(engine.document.getDocument()).toBe(before); + expect(engine.stores.canUndo.get()).toBe(false); + expect(bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + engine.lifecycle.dispose(); + }); + + it('cropLayerToBbox reports missing, locked, unsupported, and not-ready layers explicitly', async () => { + const polygon: CanvasLayerSourceContract = { + fill: '#000', + height: 10, + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 5, y: 10 }, + ], + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 10, + }; + const layers = [ + { ...rasterLayer('locked'), isLocked: true }, + { ...rasterLayer('polygon'), source: polygon }, + rasterLayer('pending'), + ]; + const document = { ...makeDoc(), layers }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.reject(new Error('decode failed')), + projectId, + store, + }); + + expect(await engine.layers.cropLayerToBbox('missing')).toEqual({ status: 'missing' }); + expect(await engine.layers.cropLayerToBbox('locked')).toEqual({ status: 'locked' }); + expect(await engine.layers.cropLayerToBbox('polygon')).toEqual({ status: 'unsupported' }); + expect(await engine.layers.cropLayerToBbox('pending')).toEqual({ status: 'not-ready' }); + engine.lifecycle.dispose(); + }); + + it('cropLayerToBbox returns not-ready without mutation when the source changes during rasterization', async () => { + const pending = createDeferred(); + const document = { ...makeDoc(), layers: [rasterLayer('a', { imageName: 'A' })] }; + const { setDocument, store } = createReactiveStore(document); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => pending.promise, + projectId: 'p1', + store, + }); + + const crop = engine.layers.cropLayerToBbox('a'); + setDocument({ ...document, layers: [rasterLayer('a', { imageName: 'B' })] }); + pending.resolve(new Blob()); + + expect(await crop).toEqual({ status: 'not-ready' }); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + engine.lifecycle.dispose(); + }); + + it('cropLayerToBbox aborts when an operation starts during its deferred export', async () => { + const pending = createDeferred(); + const document = { ...makeDoc(), layers: [rasterLayer('operation'), rasterLayer('a')] }; + const { projectId, store } = createReducerBackedStore(document); + const bitmapStore = createSpyBitmapStore(); + const resolver = vi.fn((imageName: string) => (imageName === 'a' ? pending.promise : Promise.resolve(new Blob()))); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: resolver, + projectId, + store, + }); + const operationExport = await engine.exports.exportLayerPixels('operation'); + if (operationExport.status !== 'ok') { + throw new Error('expected an operation guard'); + } + (store.dispatch as Mock).mockClear(); + bitmapStore.markLayerDirty.mockClear(); + + const crop = engine.layers.cropLayerToBbox('a'); + await vi.waitFor(() => expect(resolver).toHaveBeenCalledWith('a', expect.any(AbortSignal))); + const cleanupPreview = vi.fn(); + getCanvasOperations(engine).controller.start({ + cleanupPreview, + guard: operationExport.guard, + identity: { kind: 'filter', layerId: 'operation', projectId }, + }); + pending.resolve(new Blob()); + + await expect(crop).resolves.toEqual({ status: 'busy' }); + expect(engine.document.getDocument()).toEqual(document); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(false); + expect(cleanupPreview).not.toHaveBeenCalled(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toMatchObject({ + identity: { kind: 'filter', layerId: 'operation' }, + status: 'active', + }); + engine.lifecycle.dispose(); + }); + + it('cropLayerToBbox reports busy during an open gesture', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + const paint = { + ...rasterLayer('paint'), + source: { bitmap: { height: 10, imageName: 'paint', width: 10 }, type: 'paint' as const }, + }; + const busyHarness = createReducerBackedStore({ ...makeDoc(), layers: [paint], selectedLayerId: 'paint' }); + const busyEngine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: busyHarness.projectId, + store: busyHarness.store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + busyEngine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + busyEngine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(2, 2)); + expect(await busyEngine.layers.cropLayerToBbox('paint')).toEqual({ status: 'busy' }); + overlay.fire('pointerup', pointerAt(2, 2, { buttons: 0 })); + busyEngine.lifecycle.dispose(); + }); + + it('cropLayerToBbox reports failed for unexpected raster errors', async () => { + const base = createTestStubRasterBackend(); + const failedHarness = createReducerBackedStore({ + ...makeDoc(), + bbox: { height: 7, width: 6, x: 2, y: 3 }, + }); + const failedEngine = createCanvasEngine({ + backend: { + ...base, + createSurface: (width, height) => { + if (width === 6 && height === 7) { + throw new Error('crop allocation failed'); + } + return base.createSurface(width, height); + }, + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId: failedHarness.projectId, + store: failedHarness.store, + }); + expect(await failedEngine.layers.cropLayerToBbox('a')).toEqual({ + message: 'crop allocation failed', + status: 'failed', + }); + failedEngine.lifecycle.dispose(); + }); + + it('copyLayerToRaster finishes in its bound project after another project becomes active', async () => { + const pending = createDeferred(); + const document = makeDoc(); + const { setActiveProjectId, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => pending.promise, + projectId: 'p1', + store, + }); + + const copy = engine.layers.copyLayerToRaster('a'); + setActiveProjectId('p2'); + pending.resolve(new Blob()); + + expect(await copy).not.toBeNull(); + expect(store.dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'applyCanvasLayerStackMutation' })); + engine.lifecycle.dispose(); + }); + + it('copyLayerToRaster adds a baked paint copy directly above the source layer', async () => { + const doc = { ...makeDoc(), layers: [rasterLayer('top'), rasterLayer('a')] }; + const { projectId, store } = createReducerBackedStore(doc); + const dispatch = store.dispatch as Mock; + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const engine = createCanvasEngine({ + backend: { + ...base, + createSurface: (width, height) => { + const surface = base.createSurface(width, height); + surfaces.push(surface); + return surface; + }, + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + + const newId = await engine.layers.copyLayerToRaster('a'); + + expect(newId).toMatch(/^layer-/); + const mutation = dispatch.mock.calls + .map((call) => call[0] as EngineTestAction) + .find((action) => action.type === 'applyCanvasLayerStackMutation'); + expect(mutation).toBeDefined(); + const added = mutation?.type === 'applyCanvasLayerStackMutation' ? mutation.add : undefined; + const addedLayer = added?.layers[0]; + if (added && addedLayer?.type === 'raster') { + expect(added.index).toBe(1); + expect(addedLayer.id).toBe(newId); + expect(addedLayer.name).toBe('a copy'); + expect(addedLayer.source).toEqual({ bitmap: null, offset: { x: 0, y: 0 }, type: 'paint' }); + expect(addedLayer.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }); + } else { + throw new Error('expected atomic layer-stack mutation with raster copy'); + } + expect( + surfaces.some( + (surface) => + surface.width === 10 && surface.height === 10 && surface.callLog.some((entry) => entry.op === 'drawImage') + ) + ).toBe(true); + engine.lifecycle.dispose(); + }); + + it('copyLayerToRaster returns null for empty layers', async () => { + const empty = { ...rasterLayer('empty'), source: { bitmap: null, type: 'paint' } as const }; + const { store } = createFakeStore({ ...makeDoc(), layers: [empty] }); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + expect(await engine.layers.copyLayerToRaster('empty')).toBeNull(); + expect(dispatch).not.toHaveBeenCalled(); + engine.lifecycle.dispose(); + }); + + it('copyLayerToRaster records mask copies in engine history', async () => { + const mask: CanvasInpaintMaskLayerContract = { + ...maskLayer('mask'), + mask: { + bitmap: { height: 10, imageName: 'mask-image', width: 10 }, + fill: { color: '#e07575', style: 'diagonal' }, + }, + }; + const { projectId, store } = createReducerBackedStore({ ...makeDoc(), layers: [mask] }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + + const newId = await engine.layers.copyLayerToRaster('mask'); + expect(engine.document.getDocument()!.layers.some((layer) => layer.id === newId)).toBe(true); + + engine.history.undo(); + expect(engine.document.getDocument()!.layers.some((layer) => layer.id === newId)).toBe(false); + engine.history.redo(); + expect(engine.document.getDocument()!.layers.some((layer) => layer.id === newId)).toBe(true); + engine.lifecycle.dispose(); + }); + + it('copyLayerToRaster redo cache failure leaves the copy absent and retryable', async () => { + const { projectId, store } = createReducerBackedStore(makeDoc()); + const base = createTestStubRasterBackend(); + let failNextAllocation = false; + const engine = createCanvasEngine({ + backend: { + ...base, + createSurface: (width, height) => { + if (failNextAllocation) { + failNextAllocation = false; + throw new Error('copy replay allocation failed'); + } + return base.createSurface(width, height); + }, + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + + const newId = await engine.layers.copyLayerToRaster('a'); + expect(newId).not.toBeNull(); + engine.history.undo(); + expect(engine.document.getDocument()!.layers.filter((layer) => layer.id === newId)).toHaveLength(0); + + failNextAllocation = true; + expect(() => engine.history.redo()).toThrow('copy replay allocation failed'); + + expect(engine.document.getDocument()!.layers.filter((layer) => layer.id === newId)).toHaveLength(0); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + + engine.history.redo(); + expect(engine.document.getDocument()!.layers.filter((layer) => layer.id === newId)).toHaveLength(1); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.history.undo(); + expect(engine.document.getDocument()!.layers.filter((layer) => layer.id === newId)).toHaveLength(0); + engine.lifecycle.dispose(); + }); + + it('setTool switches the active tool and updates the store', () => { + const { engine } = createEngine(); + const listener = vi.fn(); + engine.stores.activeTool.subscribe(listener); + + engine.tools.setTool('brush'); + expect(engine.stores.activeTool.get()).toBe('brush'); + expect(listener).toHaveBeenCalledTimes(1); + + // Setting the same tool again is a no-op. + engine.tools.setTool('brush'); + expect(listener).toHaveBeenCalledTimes(1); + engine.lifecycle.dispose(); + }); + + it('interaction lock forces view and refuses non-view tools until unlocked', () => { + const { engine } = createEngine(); + + engine.tools.setTool('brush'); + expect(engine.stores.activeTool.get()).toBe('brush'); + + engine.tools.setInteractionLocked(true); + expect(engine.stores.activeTool.get()).toBe('view'); + + engine.tools.setTool('bbox'); + expect(engine.stores.activeTool.get()).toBe('view'); + + engine.tools.setTool('colorPicker'); + expect(engine.stores.activeTool.get()).toBe('view'); + + engine.tools.setInteractionLocked(false); + engine.tools.setTool('bbox'); + expect(engine.stores.activeTool.get()).toBe('bbox'); + + engine.lifecycle.dispose(); + }); + + it('registers the brush and eraser tools', () => { + const { engine } = createEngine(); + engine.tools.setTool('brush'); + expect(engine.stores.activeTool.get()).toBe('brush'); + engine.tools.setTool('eraser'); + expect(engine.stores.activeTool.get()).toBe('eraser'); + engine.lifecycle.dispose(); + }); + + it('onStrokeCommitted returns an unsubscribe and never fires without a stroke', () => { + const { engine } = createEngine(); + const listener = vi.fn(); + const unsubscribe = engine.tools.onStrokeCommitted(listener); + expect(typeof unsubscribe).toBe('function'); + unsubscribe(); + expect(listener).not.toHaveBeenCalled(); + engine.lifecycle.dispose(); + }); + + it('resize records the viewport size and clamps the device-pixel ratio', () => { + const { engine } = createEngine(); + engine.surface.resize(800, 600, 4); + expect(engine.viewport.getViewport().getViewportSize()).toEqual({ height: 600, width: 800 }); + expect(engine.viewport.getViewport().getDpr()).toBe(2); + engine.lifecycle.dispose(); + }); + + it('dispose removes both store subscriptions', () => { + const { engine, unsubscribe } = createEngine(); + expect(unsubscribe).not.toHaveBeenCalled(); + engine.lifecycle.dispose(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it('dispose is idempotent', () => { + const { engine, unsubscribe } = createEngine(); + engine.lifecycle.dispose(); + engine.lifecycle.dispose(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); +}); + +describe('document mirror wiring: layer reorder', () => { + it('recomposites in the new z-order without re-rasterizing any layer', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const a = rasterLayer('a', { opacity: 0.25 }); + const b = rasterLayer('b', { opacity: 0.75 }); + const doc: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [a, b], + selectedLayerId: null, + version: 2, + width: 100, + }; + const { setDocument, store } = createReactiveStore(doc); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + + // Initial frame: dispatches (and, after a microtask flush, completes) the + // rasterize for both layers. + raf.flush(); + await flushMicrotasks(); + raf.flush(); // the follow-up frame each rasterize's resolve scheduled + expect(resolver).toHaveBeenCalledTimes(2); + + // Isolate the reorder-triggered frame's draw log. + screen.surface.callLog.length = 0; + + // Pure reorder: new array reference, same layer object references, swapped order. + setDocument({ ...doc, layers: [b, a] }); + raf.flush(); + + // No layer content changed, so nothing should be re-rasterized. + expect(resolver).toHaveBeenCalledTimes(2); + + // The compositor draws bottom-to-top; `a` (alpha 0.25) is now the bottom + // layer and `b` (alpha 0.75) the top, so alpha is applied in that order. + const alphaOrder = screen.surface.callLog + .filter((entry) => entry.op === 'set' && entry.args[0] === 'globalAlpha') + .map((entry) => entry.args[1]); + expect(alphaOrder).toEqual([0.25, 0.75]); + + engine.lifecycle.dispose(); + }); +}); + +describe('layer removal: adjusted-surface cache cleanup', () => { + it("drops the removed layer's adjusted surface (no cache leak)", async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + adjustedSurfaceCacheDeletes.length = 0; + + const doc: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [rasterLayer('a'), rasterLayer('b')], + selectedLayerId: null, + version: 2, + width: 100, + }; + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + engine.surface.attach(createFakeCanvas().element, createFakeCanvas().element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + // Still present: nothing removed yet. + expect(adjustedSurfaceCacheDeletes).not.toContain('a'); + + // Remove layer 'a' via an ordinary layer-array edit (onLayersChanged → dropLayer). + setDocument({ ...doc, layers: [rasterLayer('b')] }); + raf.flush(); + + // The removed layer's adjusted-surface slot is dropped alongside its layer cache. + expect(adjustedSurfaceCacheDeletes).toContain('a'); + + engine.lifecycle.dispose(); + }); +}); + +describe('resize: synchronous composite (no flash/strobe on panel drag)', () => { + it('composites in the SAME task as the backing-store resize, scheduling no deferred frame', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const doc = makeDoc(); + const { store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + + // Run the initial attach frame so the surface holds a composited frame, then + // isolate what the resize alone draws. + raf.flush(); + screen.surface.callLog.length = 0; + + // A single resize event (as a ResizeObserver fires mid panel-drag). Sizing a + // `` backing store CLEARS it; the fix recomposites synchronously so the + // cleared surface is repainted before the browser paints (no blank frame). + // No `raf.flush()` follows — so any draw seen here happened IN-TASK. + engine.surface.resize(800, 600, 1); + + // Backing store was resized in the same call... + expect(screen.element.width).toBe(800); + expect(screen.element.height).toBe(600); + // ...and the composite ran SYNCHRONOUSLY (drew into the surface without a rAF + // flush). `compositeDocument` always clears the target, so a `clearRect` here + // proves the recomposite happened in-task rather than being deferred to rAF. + const composited = screen.surface.callLog.some((entry) => entry.op === 'clearRect'); + expect(composited).toBe(true); + + engine.lifecycle.dispose(); + }); + + it('schedules NO deferred frame after the synchronous composite (exactly one composite per resize)', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const doc = makeDoc(); + const { store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + screen.surface.callLog.length = 0; + + engine.surface.resize(800, 600, 1); + + // The synchronous composite ran... + expect(screen.surface.callLog.some((entry) => entry.op === 'clearRect')).toBe(true); + // ...and setViewportSize's viewport-subscription `{ view: true }` invalidate was + // suppressed, so NO rAF frame is queued to recomposite the identical content. + expect(raf.pendingCount()).toBe(0); + + // Draining any frame anyway must not produce a second composite. + screen.surface.callLog.length = 0; + raf.flush(); + expect(screen.surface.callLog.some((entry) => entry.op === 'clearRect')).toBe(false); + + engine.lifecycle.dispose(); + }); + + it('marks the composite dirty flag so the synchronous path is not gated out (T22)', () => { + // A resize must force a full recomposite even though no layer pixels changed: + // if it only marked `overlay`, the T22 `needsComposite` gate would skip the + // composite and the just-cleared screen surface would stay blank. + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const doc = makeDoc(); + const { store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + screen.surface.callLog.length = 0; + + engine.surface.resize(320, 240, 2); + + // The screen (document composite) surface — not just the overlay — was redrawn. + expect(screen.surface.callLog.some((entry) => entry.op === 'clearRect')).toBe(true); + engine.lifecycle.dispose(); + }); +}); + +describe('ensureLayerCaches: edit-during-rasterize race', () => { + it('re-rasterizes with the newest source when an edit lands while a rasterize is in flight', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const doc = makeDoc(); // single layer 'a', imageName 'a' + const { setDocument, store } = createReactiveStore(doc); + + const deferreds = new Map>>(); + const resolver = vi.fn((imageName: string) => { + const deferred = createDeferred(); + deferreds.set(imageName, deferred); + return deferred.promise; + }); + + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + + // Subscribe to thumbnail-version notifications for layer 'a' *before* either + // rasterize settles. A bare `.get()` check at the end can't distinguish + // "notified once, at the wrong time, with stale pixels on the surface" from + // "notified once, at the right time, with fresh pixels on the surface" -- + // both leave the same final version number. Tracking call count/timing does. + const thumbnailListener = vi.fn(); + const unsubscribe = engine.stores.thumbnailVersion.subscribeKey('a', thumbnailListener); + + // Frame 1: dispatches the first rasterize for imageName 'a'; it stays in flight + // (the deferred is never auto-resolved). + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenNthCalledWith(1, 'a', expect.any(AbortSignal)); + + // An edit lands mid-flight: same layer id, new object reference, new source. + setDocument({ ...doc, layers: [rasterLayer('a', { imageName: 'a-v2' })] }); + + // A frame runs while the first rasterize is still in flight. The source no + // longer matches that job, so a fresh isolated job starts immediately. + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(2); + expect(resolver).toHaveBeenNthCalledWith(2, 'a-v2', expect.any(AbortSignal)); + + // The newer rasterize wins and publishes while the old decode is pending. + deferreds.get('a-v2')!.resolve(new Blob()); + await flushMicrotasks(); + raf.flush(); + expect(thumbnailListener).toHaveBeenCalledTimes(1); + expect(engine.stores.thumbnailVersion.get('a')).toBe(2); + + // The older decode resolves afterwards. It may finish its isolated scratch + // draw, but it must neither publish nor notify subscribers. + deferreds.get('a')!.resolve(new Blob()); + await flushMicrotasks(); + raf.flush(); + expect(thumbnailListener).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledTimes(2); + + unsubscribe(); + engine.lifecycle.dispose(); + }); +}); + +// ---- Engine-owned history (P2.3) -------------------------------------- +// +// Drives a real brush stroke end-to-end through the engine (attach → dispatch +// pointer events → commit) and asserts the history wiring: strokeCommitted pushes +// an image patch, undo/redo write the before/after pixels back into the layer's +// cache surface AND re-mark the layer dirty for persistence, and the canUndo / +// canRedo stores track the stacks. + +const paintDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'normal', + id: 'paint1', + isEnabled: true, + isLocked: false, + name: 'paint1', + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + selectedLayerId: 'paint1', + version: 2, + width: 100, +}); + +type ControlPaintHarnessOverrides = Partial> & { + source: CanvasControlLayerContract['source']; +}; + +interface ControlPaintHarnessOptions { + bitmapStoreFactory?: (deps: { + backend: StubRasterBackend; + projectId: string; + store: EngineStore; + }) => ReturnType; + pixelWrites?: { enabled: boolean }; +} + +const createControlPaintHarness = ( + overrides: ControlPaintHarnessOverrides, + options: ControlPaintHarnessOptions = {} +) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { source, ...layerOverrides } = overrides; + const layer: CanvasControlLayerContract = { + adapter: { + beginEndStepPct: [0.1, 0.9], + controlMode: 'more_control', + kind: 'controlnet', + model: 'control-model', + weight: 0.7, + }, + blendMode: 'screen', + filter: { settings: { low: 10 }, type: 'canny' }, + id: 'control', + isEnabled: true, + isLocked: false, + name: 'Control', + opacity: 0.6, + source, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: true, + ...layerOverrides, + }; + const document: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [layer], + selectedLayerId: layer.id, + version: 2, + width: 100, + }; + const { projectId, store } = createReducerBackedStore(document); + const baseBackend = createTestStubRasterBackend(); + const pixelWrites = options.pixelWrites ?? { enabled: true }; + const backend: StubRasterBackend = { + ...baseBackend, + createSurface: (width, height) => { + const surface = baseBackend.createSurface(width, height); + const originalCtx = surface.ctx; + const ctx = new Proxy(originalCtx, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (property !== 'getImageData' || typeof value !== 'function') { + return value; + } + return (...args: unknown[]) => { + const imageData = Reflect.apply(value, target, args) as ImageData; + if (pixelWrites.enabled && imageData.data.length > 0) { + const drawCount = surface.callLog.filter((entry) => entry.op === 'drawImage').length; + imageData.data[0] = drawCount % 256; + } + return imageData; + }; + }, + }); + Object.defineProperty(surface, 'ctx', { configurable: true, value: ctx }); + return surface; + }, + }; + const bitmapStore = options.bitmapStoreFactory?.({ backend, projectId, store }) ?? createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const strokes: StrokeCommittedEvent[] = []; + engine.tools.onStrokeCommitted((event) => strokes.push(event)); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + + return { + backend, + bitmapStore, + document, + engine, + layer, + overlay, + publishInitialCache: async () => { + raf.flush(); + await flushMicrotasks(); + raf.flush(); + await flushMicrotasks(); + }, + raf, + screen, + store, + strokes, + }; +}; + +const createControlSelectionHarness = (overrides: ControlPaintHarnessOverrides) => { + const selectionPixelWrites = { enabled: true }; + return { + ...createControlPaintHarness(overrides, { pixelWrites: selectionPixelWrites }), + setSelectionPixelWrites: (enabled: boolean) => { + selectionPixelWrites.enabled = enabled; + }, + }; +}; + +/** A minimal in-memory bitmap store: records dirty-marks, never touches the network. */ +const createSpyBitmapStore = (): BitmapStore & { + markLayerDirty: Mock<(layerId: string) => void>; + releaseSuspendedLayer: Mock<() => void>; + reset: Mock<() => void>; + suspendLayer: Mock<(layerId: string) => () => void>; +} => { + const releaseSuspendedLayer = vi.fn(); + return { + discardLayer: vi.fn(), + dispose: vi.fn(), + flushPendingUploads: vi.fn(() => Promise.resolve()), + isSelfEcho: () => false, + markLayerDirty: vi.fn<(layerId: string) => void>(), + releaseSuspendedLayer, + reset: vi.fn<() => void>(), + suspendLayer: vi.fn(() => releaseSuspendedLayer), + }; +}; + +const createRealControlPersistenceHarness = async ( + uploadImage: (blob: Blob) => Promise<{ height: number; imageName: string; width: number }> +) => { + let placed: { offset: { x: number; y: number }; surface: RasterSurface } | null = null; + const encodeSurface = vi.fn(() => Promise.resolve(new Blob(['control-pixels'], { type: 'image/png' }))); + const h = createControlPaintHarness( + { source: { bitmap: { height: 10, imageName: 'control-paint', width: 10 }, type: 'paint' } }, + { + bitmapStoreFactory: ({ projectId, store }) => { + const real = createBitmapStore({ + debounceMs: 1500, + dispatch: createTestMutationPort(store, projectId).dispatch, + encodeSurface, + getLayerSource: (layerId) => { + const layer = store + .getState() + .projects.find((project) => project.id === projectId) + ?.canvas.document.layers.find((candidate) => candidate.id === layerId); + return layer?.type === 'control' ? layer.source : null; + }, + getLayerSurface: () => placed, + hashBlob: (blob) => blob.text(), + maxUploadAttempts: 1, + retryDelaysMs: [], + sleep: () => Promise.resolve(), + uploadImage, + }); + const releaseSuspendedLayer = vi.fn(); + return { + ...real, + markLayerDirty: vi.fn(real.markLayerDirty), + releaseSuspendedLayer, + reset: vi.fn(real.reset), + suspendLayer: vi.fn((layerId: string) => { + const release = real.suspendLayer(layerId); + return () => { + releaseSuspendedLayer(); + release(); + }; + }), + }; + }, + } + ); + await h.publishInitialCache(); + const exported = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + if (exported.status !== 'ok') { + throw new Error(`expected ready direct control cache, got ${exported.status}`); + } + placed = { offset: { x: exported.rect.x, y: exported.rect.y }, surface: exported.surface }; + return { ...h, encodeSurface }; +}; + +/** A fake canvas that also lets a test fire pointer events at the engine's listeners. */ +const createInputCanvas = ( + width = 100, + height = 100 +): { + element: HTMLCanvasElement; + fire: (type: string, event: Partial) => void; + surface: StubRasterSurface; +} => { + const surface = createTestStubRasterBackend().createSurface(width, height); + const listeners = new Map void>>(); + const element = { + addEventListener: (type: string, handler: (event: Event) => void) => { + const set = listeners.get(type) ?? new Set(); + set.add(handler); + listeners.set(type, set); + }, + getBoundingClientRect: () => ({ bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0 }), + getContext: () => surface.ctx, + height, + releasePointerCapture: () => {}, + removeEventListener: (type: string, handler: (event: Event) => void) => { + listeners.get(type)?.delete(handler); + }, + setPointerCapture: () => {}, + width, + } as unknown as HTMLCanvasElement; + const fire = (type: string, event: Partial): void => { + for (const handler of listeners.get(type) ?? []) { + handler({ preventDefault: () => {}, ...event } as unknown as Event); + } + }; + return { element, fire, surface }; +}; + +const pointerAt = (x: number, y: number, opts: { button?: number; buttons?: number } = {}): Partial => ({ + altKey: false, + button: opts.button ?? 0, + buttons: opts.buttons ?? 1, + clientX: x, + clientY: y, + ctrlKey: false, + metaKey: false, + pointerId: 1, + pointerType: 'mouse', + pressure: 0.5, + shiftKey: false, + timeStamp: 0, +}); + +/** Every `putImageData` recorded across all surfaces the backend created. */ +const putImageDataCalls = (surfaces: StubRasterSurface[]): { image: unknown; x: unknown; y: unknown }[] => + surfaces.flatMap((surface) => + surface.callLog + .filter((entry) => entry.op === 'putImageData') + .map((entry) => ({ image: entry.args[0], x: entry.args[1], y: entry.args[2] })) + ); + +describe('engine-owned control pixel editing', () => { + it('brushes an empty control in place and never creates a raster layer', () => { + const h = createControlPaintHarness({ source: { bitmap: null, type: 'paint' } }); + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(20, 20)); + h.overlay.fire('pointermove', pointerAt(40, 40)); + h.overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + + expect(h.engine.document.getDocument()!.layers).toHaveLength(1); + expect(h.engine.document.getDocument()!.layers[0]).toMatchObject({ id: 'control', type: 'control' }); + expect(h.strokes).toHaveLength(1); + expect(h.strokes[0]!.layerId).toBe('control'); + expect(h.bitmapStore.markLayerDirty).toHaveBeenCalledWith('control'); + expect(h.bitmapStore.suspendLayer).toHaveBeenCalledWith('control'); + expect(h.bitmapStore.markLayerDirty.mock.invocationCallOrder[0]).toBeLessThan( + h.bitmapStore.releaseSuspendedLayer.mock.invocationCallOrder[0]! + ); + expect(h.engine.stores.canUndo.get()).toBe(true); + h.engine.lifecycle.dispose(); + }); + + it('restores the exact direct cache after a cancelled stroke grows it and permits a subsequent edit', async () => { + const h = createControlPaintHarness({ + source: { bitmap: { height: 10, imageName: 'control-paint', width: 10 }, type: 'paint' }, + }); + await h.publishInitialCache(); + const before = await snapshotLayerCache(h.engine, 'control'); + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(80, 80)); + h.overlay.fire('pointermove', pointerAt(90, 90)); + h.overlay.fire('pointercancel', pointerAt(90, 90, { buttons: 0 })); + + const restored = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + expect(restored.status).toBe('ok'); + if (restored.status === 'ok') { + expect(restored.surface).toBe(before.surface); + expect(restored.rect).toEqual(before.rect); + expect(restored.guard.cacheVersion).toBe(before.version); + const finalWrite = (restored.surface as StubRasterSurface).callLog + .filter((entry) => entry.op === 'putImageData') + .at(-1); + expect(finalWrite?.args[0]).toMatchObject({ height: before.rect.height, width: before.rect.width }); + } + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.strokes).toHaveLength(0); + + h.overlay.fire('pointerdown', pointerAt(20, 20)); + expect(h.bitmapStore.suspendLayer).toHaveBeenCalledTimes(2); + h.overlay.fire('pointercancel', pointerAt(20, 20, { buttons: 0 })); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledTimes(2); + h.engine.lifecycle.dispose(); + }); + + it.each(['brush', 'eraser'] as const)( + 'treats a byte-identical direct %s stroke as an exact rollback', + async (tool) => { + const h = createControlPaintHarness( + { source: { bitmap: { height: 10, imageName: 'control-paint', width: 10 }, type: 'paint' } }, + { pixelWrites: { enabled: false } } + ); + await h.publishInitialCache(); + const before = await snapshotLayerCache(h.engine, 'control'); + h.engine.tools.setTool(tool); + h.overlay.fire('pointerdown', pointerAt(5, 5)); + h.overlay.fire('pointermove', pointerAt(8, 8)); + h.overlay.fire('pointerup', pointerAt(8, 8, { buttons: 0 })); + + const restored = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + expect(restored.status).toBe('ok'); + if (restored.status === 'ok') { + expect(restored.surface).toBe(before.surface); + expect(restored.rect).toEqual(before.rect); + expect(restored.guard.cacheVersion).toBe(before.version); + } + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.strokes).toHaveLength(0); + h.engine.lifecycle.dispose(); + } + ); + + it('keeps a pending direct-control upload suspended through cancel, then resumes the preserved dirty work', async () => { + const uploadImage = vi.fn(() => Promise.resolve({ height: 10, imageName: 'resumed-control', width: 10 })); + const h = await createRealControlPersistenceHarness(uploadImage); + vi.useFakeTimers(); + try { + h.bitmapStore.markLayerDirty('control'); + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(20, 20)); + h.overlay.fire('pointermove', pointerAt(30, 30)); + const barrier = h.bitmapStore.flushPendingUploads(); + let settled = false; + void barrier.then(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(3000); + expect(settled).toBe(false); + expect(h.encodeSurface).not.toHaveBeenCalled(); + expect(uploadImage).not.toHaveBeenCalled(); + + h.overlay.fire('pointercancel', pointerAt(30, 30, { buttons: 0 })); + await drainMicrotasksUntil(() => settled); + + expect(settled).toBe(true); + expect(h.encodeSurface).toHaveBeenCalledOnce(); + expect(uploadImage).toHaveBeenCalledOnce(); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.strokes).toHaveLength(0); + h.engine.lifecycle.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('invalidates an in-flight direct-control upload on begin and releases the barrier after cancel', async () => { + const uploads = [ + createDeferred<{ height: number; imageName: string; width: number }>(), + createDeferred<{ height: number; imageName: string; width: number }>(), + ]; + let uploadIndex = 0; + const uploadImage = vi.fn(() => uploads[uploadIndex++]!.promise); + const h = await createRealControlPersistenceHarness(uploadImage); + h.bitmapStore.markLayerDirty('control'); + const barrier = h.bitmapStore.flushPendingUploads(); + for (let tick = 0; tick < 50 && uploadImage.mock.calls.length < 1; tick += 1) { + await Promise.resolve(); + } + expect(uploadImage).toHaveBeenCalledOnce(); + + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(20, 20)); + h.overlay.fire('pointermove', pointerAt(30, 30)); + let settled = false; + void barrier.then(() => { + settled = true; + }); + uploads[0]!.resolve({ height: 10, imageName: 'obsolete-control', width: 10 }); + await Promise.resolve(); + await Promise.resolve(); + + expect(settled).toBe(false); + expect(uploadImage).toHaveBeenCalledOnce(); + + h.overlay.fire('pointercancel', pointerAt(30, 30, { buttons: 0 })); + for (let tick = 0; tick < 50 && uploadImage.mock.calls.length < 2; tick += 1) { + await Promise.resolve(); + } + expect(uploadImage).toHaveBeenCalledTimes(2); + uploads[1]!.resolve({ height: 10, imageName: 'resumed-control', width: 10 }); + await drainMicrotasksUntil(() => settled); + + expect(settled).toBe(true); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.strokes).toHaveLength(0); + h.engine.lifecycle.dispose(); + }); + + it.each([ + ['image control', { source: { image: { height: 10, imageName: 'control-image', width: 10 }, type: 'image' } }], + [ + 'transformed empty control', + { + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 2, scaleY: 3, x: 7, y: 11 }, + }, + ], + ] as const)('treats byte-identical brush and eraser strokes on a %s as rollback', async (_scenario, overrides) => { + for (const tool of ['brush', 'eraser'] as const) { + const h = createControlPaintHarness(overrides, { pixelWrites: { enabled: false } }); + if (overrides.source.type === 'image') { + await h.publishInitialCache(); + } + const beforeDocument = structuredClone(h.engine.document.getDocument()); + const beforeCache = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + h.engine.tools.setTool(tool); + h.overlay.fire('pointerdown', pointerAt(5, 5)); + h.overlay.fire('pointermove', pointerAt(8, 8)); + h.overlay.fire('pointerup', pointerAt(8, 8, { buttons: 0 })); + + expect(h.engine.document.getDocument()).toEqual(beforeDocument); + const restored = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + expect(restored.status).toBe(beforeCache.status); + if (restored.status === 'ok' && beforeCache.status === 'ok') { + expect(restored.surface).toBe(beforeCache.surface); + expect(restored.rect).toEqual(beforeCache.rect); + expect(restored.guard.cacheVersion).toBe(beforeCache.guard.cacheVersion); + } + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.strokes).toHaveLength(0); + h.engine.lifecycle.dispose(); + } + }); + + it('restores a direct control stroke when image-patch history preparation fails', async () => { + const h = createControlPaintHarness({ source: { bitmap: null, type: 'paint' } }); + const before = structuredClone(h.engine.document.getDocument()); + const beforeExport = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(20, 20)); + h.overlay.fire('pointermove', pointerAt(40, 40)); + historyPreparationFaults.imagePatch = true; + + expect(() => h.overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 }))).toThrow( + 'image patch preparation failed' + ); + + expect(h.engine.document.getDocument()).toEqual(before); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.strokes).toHaveLength(0); + const restored = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + expect(restored.status).toBe(beforeExport.status); + h.engine.lifecycle.dispose(); + }); + + it('materializes an image control plus brush stroke as one reversible edit', async () => { + const h = createControlPaintHarness({ + source: { image: { height: 10, imageName: 'control-image', width: 20 }, type: 'image' }, + transform: { rotation: 0, scaleX: 2, scaleY: 3, x: 7, y: 11 }, + }); + await h.publishInitialCache(); + const before = structuredClone(h.engine.document.getDocument()!.layers[0]); + + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(20, 20)); + h.overlay.fire('pointermove', pointerAt(25, 25)); + h.overlay.fire('pointerup', pointerAt(25, 25, { buttons: 0 })); + + const after = structuredClone(h.engine.document.getDocument()!.layers[0]); + expect(after).toMatchObject({ + adapter: before && 'adapter' in before ? before.adapter : undefined, + filter: before && 'filter' in before ? before.filter : undefined, + id: 'control', + source: { type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: true, + }); + expect(h.engine.stores.canUndo.get()).toBe(true); + expect(h.bitmapStore.suspendLayer).toHaveBeenCalledWith('control'); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.bitmapStore.markLayerDirty.mock.invocationCallOrder[0]).toBeLessThan( + h.bitmapStore.releaseSuspendedLayer.mock.invocationCallOrder[0]! + ); + + h.engine.history.undo(); + expect(h.engine.document.getDocument()!.layers[0]).toEqual(before); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.engine.stores.canRedo.get()).toBe(true); + + h.engine.history.redo(); + expect(h.engine.document.getDocument()!.layers[0]).toEqual(after); + h.engine.lifecycle.dispose(); + }); + + it('restores a materialized control when layer-snapshot history preparation fails', async () => { + const h = createControlPaintHarness({ + source: { image: { height: 10, imageName: 'control-image', width: 20 }, type: 'image' }, + transform: { rotation: 0, scaleX: 2, scaleY: 3, x: 7, y: 11 }, + }); + await h.publishInitialCache(); + const before = structuredClone(h.engine.document.getDocument()); + const beforeCache = await snapshotLayerCache(h.engine, 'control'); + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(20, 20)); + h.overlay.fire('pointermove', pointerAt(25, 25)); + historyPreparationFaults.layerSnapshot = true; + + expect(() => h.overlay.fire('pointerup', pointerAt(25, 25, { buttons: 0 }))).toThrow( + 'layer snapshot preparation failed' + ); + + expect(h.engine.document.getDocument()).toEqual(before); + const restored = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + expect(restored.status).toBe('ok'); + if (restored.status === 'ok') { + expect(restored.surface).toBe(beforeCache.surface); + expect(restored.rect).toEqual(beforeCache.rect); + expect(restored.guard.cacheVersion).toBe(beforeCache.version); + } + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.strokes).toHaveLength(0); + h.engine.lifecycle.dispose(); + }); + + it('releases materialized persistence and edit ownership when rollback cleanup throws', async () => { + const h = createControlPaintHarness({ + source: { image: { height: 10, imageName: 'control-image', width: 20 }, type: 'image' }, + transform: { rotation: 0, scaleX: 2, scaleY: 3, x: 7, y: 11 }, + }); + await h.publishInitialCache(); + const before = structuredClone(h.engine.document.getDocument()); + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(20, 20)); + h.overlay.fire('pointermove', pointerAt(25, 25)); + historyPreparationFaults.layerSnapshot = true; + adjustedSurfaceCacheDeleteFaults.add('control'); + + expect(() => h.overlay.fire('pointerup', pointerAt(25, 25, { buttons: 0 }))).toThrow( + 'adjusted surface cache delete failed' + ); + + expect(h.engine.document.getDocument()).toEqual(before); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.strokes).toHaveLength(0); + + historyPreparationFaults.layerSnapshot = false; + adjustedSurfaceCacheDeleteFaults.clear(); + h.overlay.fire('pointerdown', pointerAt(30, 30)); + expect(h.bitmapStore.suspendLayer).toHaveBeenCalledTimes(2); + h.overlay.fire('pointercancel', pointerAt(30, 30, { buttons: 0 })); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledTimes(2); + h.engine.lifecycle.dispose(); + }); + + it.each(['eraser', 'brush'] as const)( + 'rolls back a prepared image control when a %s gesture is cancelled', + async (tool) => { + const h = createControlPaintHarness({ + source: { image: { height: 10, imageName: 'control-image', width: 10 }, type: 'image' }, + }); + await h.publishInitialCache(); + const before = structuredClone(h.engine.document.getDocument()); + h.engine.tools.setTool(tool); + h.overlay.fire('pointerdown', pointerAt(5, 5)); + h.overlay.fire('pointercancel', pointerAt(5, 5, { buttons: 0 })); + expect(h.engine.document.getDocument()).toEqual(before); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.bitmapStore.suspendLayer).toHaveBeenCalledWith('control'); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + h.engine.lifecycle.dispose(); + } + ); + + it.each([ + ['locked', { isLocked: true }], + ['disabled', { isEnabled: false }], + ] as const)('does not mutate or auto-create over a %s control', (_scenario, patch) => { + const h = createControlPaintHarness({ source: { bitmap: null, type: 'paint' }, ...patch }); + const before = structuredClone(h.engine.document.getDocument()); + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(20, 20)); + h.overlay.fire('pointerup', pointerAt(20, 20, { buttons: 0 })); + expect(h.engine.document.getDocument()).toEqual(before); + expect(h.engine.stores.canUndo.get()).toBe(false); + h.engine.lifecycle.dispose(); + }); + + it('normalizes a transformed empty control before its first brush stroke', () => { + const h = createControlPaintHarness({ + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 2, scaleY: 2, x: 30, y: 40 }, + }); + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(35, 45)); + h.overlay.fire('pointerup', pointerAt(35, 45, { buttons: 0 })); + expect(h.engine.document.getDocument()!.layers[0]).toMatchObject({ + id: 'control', + source: { type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + }); + h.engine.history.undo(); + expect(h.engine.document.getDocument()!.layers[0]).toMatchObject({ + transform: { rotation: 0, scaleX: 2, scaleY: 2, x: 30, y: 40 }, + }); + h.engine.lifecycle.dispose(); + }); + + it('does not edit or auto-create when an image control cache is not ready', () => { + const h = createControlPaintHarness({ + source: { image: { height: 10, imageName: 'control-image', width: 10 }, type: 'image' }, + }); + h.raf.flush(); + const before = structuredClone(h.engine.document.getDocument()); + + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(5, 5)); + h.overlay.fire('pointerup', pointerAt(5, 5, { buttons: 0 })); + + expect(h.engine.document.getDocument()).toEqual(before); + expect(h.engine.document.getDocument()!.layers).toHaveLength(1); + expect(h.strokes).toHaveLength(0); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.bitmapStore.discardLayer).not.toHaveBeenCalled(); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.engine.stores.canRedo.get()).toBe(false); + h.engine.lifecycle.dispose(); + }); + + it('finishes document-replacement cleanup after materialized gesture rollback throws', async () => { + const h = createControlPaintHarness({ + source: { image: { height: 10, imageName: 'control-image', width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 }, + }); + await h.publishInitialCache(); + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(8, 8)); + h.overlay.fire('pointermove', pointerAt(12, 12)); + adjustedSurfaceCacheDeleteFaults.add('control'); + const replacement = { ...h.document, width: h.document.width + 1 }; + + expect(() => h.store.dispatch({ document: replacement, type: 'replaceCanvasDocument' })).toThrow( + 'adjusted surface cache delete failed' + ); + + expect(h.engine.document.getDocument()).toEqual(replacement); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.bitmapStore.reset).toHaveBeenCalledOnce(); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.strokes).toHaveLength(0); + h.engine.lifecycle.dispose(); + }); + + it('finishes affected-layer removal cleanup after direct gesture rollback throws', async () => { + const h = createControlPaintHarness({ + source: { bitmap: { height: 10, imageName: 'control-paint', width: 10 }, type: 'paint' }, + }); + await h.publishInitialCache(); + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(8, 8)); + h.overlay.fire('pointermove', pointerAt(12, 12)); + adjustedSurfaceCacheDeleteFaults.add('control'); + + expect(() => h.store.dispatch({ ids: ['control'], type: 'removeCanvasLayers' })).toThrow( + 'adjusted surface cache delete failed' + ); + + expect(h.engine.document.getDocument()!.layers).toHaveLength(0); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.bitmapStore.discardLayer).toHaveBeenCalledWith('control'); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.strokes).toHaveLength(0); + h.engine.lifecycle.dispose(); + }); + + it('does not clean up a materialized gesture merely because another project becomes active', async () => { + const h = createControlPaintHarness({ + source: { image: { height: 10, imageName: 'control-image', width: 10 }, type: 'image' }, + }); + await h.publishInitialCache(); + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(8, 8)); + h.overlay.fire('pointermove', pointerAt(12, 12)); + adjustedSurfaceCacheDeleteFaults.add('control'); + + expect(() => h.store.dispatch({ type: 'createProject' })).not.toThrow(); + + expect(h.store.getState().activeProjectId).not.toBe(h.store.getState().projects[0]!.id); + expect(h.bitmapStore.releaseSuspendedLayer).not.toHaveBeenCalled(); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.strokes).toHaveLength(0); + + adjustedSurfaceCacheDeleteFaults.delete('control'); + h.store.dispatch({ projectId: h.store.getState().projects[0]!.id, type: 'switchProject' }); + h.overlay.fire('pointercancel', pointerAt(8, 8, { buttons: 0 })); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + h.engine.lifecycle.dispose(); + }); + + it('finishes disposal after direct gesture rollback throws and remains idempotent', async () => { + const h = createControlPaintHarness({ + source: { bitmap: { height: 10, imageName: 'control-paint', width: 10 }, type: 'paint' }, + }); + await h.publishInitialCache(); + h.engine.tools.setTool('brush'); + h.overlay.fire('pointerdown', pointerAt(8, 8)); + h.overlay.fire('pointermove', pointerAt(12, 12)); + adjustedSurfaceCacheDeleteFaults.add('control'); + + expect(() => h.engine.lifecycle.dispose()).toThrow('adjusted surface cache delete failed'); + + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.bitmapStore.dispose).toHaveBeenCalledOnce(); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.strokes).toHaveLength(0); + expect(() => h.engine.lifecycle.dispose()).not.toThrow(); + expect(h.bitmapStore.dispose).toHaveBeenCalledOnce(); + }); +}); + +describe('engine-owned history: stroke → undo → redo', () => { + const drawStroke = () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + // The engine builds real `Path2D`s for stroke outlines; node lacks it. + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const bitmapStore = createSpyBitmapStore(); + + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const strokes: StrokeCommittedEvent[] = []; + engine.tools.onStrokeCommitted((event) => strokes.push(event)); + + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('brush'); + + // A brush gesture entirely inside the 100x100 document (identity viewport). + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + + return { bitmapStore, engine, strokes, surfaces }; + }; + + it('records a stroke and restores before/after pixels on undo/redo', () => { + const { bitmapStore, engine, strokes, surfaces } = drawStroke(); + + // One stroke committed and one history entry recorded. + expect(strokes).toHaveLength(1); + const event = strokes[0]!; + expect(event.layerId).toBe('paint1'); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + // The commit marked the layer dirty for persistence. + const dirtyAfterStroke = bitmapStore.markLayerDirty.mock.calls.length; + expect(dirtyAfterStroke).toBeGreaterThanOrEqual(1); + + // Undo: the layer's cache surface receives putImageData(before), and the layer + // is re-marked dirty (convergence path). Content-sized: the paint layer started + // empty and grew to exactly the stroke's dirty rect, so the cache-local origin + // equals the dirty-rect origin and the patch lands at surface (0, 0). + engine.history.undo(); + const undoPut = putImageDataCalls(surfaces).find((call) => call.image === event.beforeImageData); + expect(undoPut).toBeDefined(); + expect(undoPut!.x).toBe(0); + expect(undoPut!.y).toBe(0); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + expect(bitmapStore.markLayerDirty.mock.calls.length).toBeGreaterThan(dirtyAfterStroke); + + // Redo: putImageData(after) restores the post-stroke pixels. + engine.history.redo(); + const redoPut = putImageDataCalls(surfaces).find((call) => call.image === event.afterImageData); + expect(redoPut).toBeDefined(); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.lifecycle.dispose(); + }); + + it('round-trips pixels exactly across a cache-growing stroke → undo → redo', () => { + // Integrated case: a multi-move stroke that GROWS the (initially empty) paint + // cache across several pointer batches, then undo/redo restore the exact + // before/after ImageData over the FULL grown extent. The piecewise pieces + // (growth, patch application) are covered elsewhere; this pins them together. + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const strokes: StrokeCommittedEvent[] = []; + engine.tools.onStrokeCommitted((event) => strokes.push(event)); + + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('brush'); + + // Drag rightward across the document in several batches so the content-sized + // cache grows (and reallocates) as the stroke extends. + overlay.fire('pointerdown', pointerAt(10, 50)); + overlay.fire('pointermove', pointerAt(30, 50)); + overlay.fire('pointermove', pointerAt(50, 50)); + overlay.fire('pointermove', pointerAt(70, 50)); + overlay.fire('pointermove', pointerAt(90, 50)); + overlay.fire('pointerup', pointerAt(90, 50, { buttons: 0 })); + + expect(strokes).toHaveLength(1); + const event = strokes[0]!; + // The stroke grew the cache well beyond a single dab: the dirty rect spans most + // of the drag width, and the captured before/after cover that full extent. + expect(event.dirtyRect.width).toBeGreaterThan(60); + expect(event.beforeImageData.width).toBe(event.dirtyRect.width); + expect(event.beforeImageData.height).toBe(event.dirtyRect.height); + expect(event.afterImageData.width).toBe(event.dirtyRect.width); + expect(event.afterImageData.height).toBe(event.dirtyRect.height); + + // Undo writes the EXACT pre-stroke ImageData back into the cache. The cache + // grew to exactly the (chunk-padded) dirty rect, so its local origin equals the + // dirty-rect origin and the patch lands at surface (0, 0). + engine.history.undo(); + const undoPut = putImageDataCalls(surfaces).find((call) => call.image === event.beforeImageData); + expect(undoPut).toBeDefined(); + expect(undoPut!.x).toBe(0); + expect(undoPut!.y).toBe(0); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + + // Redo writes the EXACT post-stroke ImageData back — a lossless round-trip. + engine.history.redo(); + const redoPut = putImageDataCalls(surfaces).find((call) => call.image === event.afterImageData); + expect(redoPut).toBeDefined(); + expect(redoPut!.x).toBe(0); + expect(redoPut!.y).toBe(0); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.lifecycle.dispose(); + }); + + it('clears history when the document is replaced', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { setDocument, store } = createReactiveStore(paintDoc()); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + expect(engine.stores.canUndo.get()).toBe(true); + + // A dims change triggers onDocumentReplaced → history.clear(). + const replaced = { ...paintDoc(), height: 200, width: 200 }; + setDocument(replaced); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.lifecycle.dispose(); + }); + + it('clears history on a same-dimension snapshot restore (documentRevision bump)', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { setDocument, store } = createReactiveStore(paintDoc()); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + expect(engine.stores.canUndo.get()).toBe(true); + + // A snapshot restore reuses the same dims AND layer ids (structuredClone), so + // only the bumped documentRevision distinguishes it from an ordinary edit. It + // must still clear history: a subsequent undo would otherwise put pre-restore + // pixels over the restored content. + setDocument(paintDoc(), 1); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.lifecycle.dispose(); + }); + + it('re-rasterizes and resets persistence bookkeeping on a revision-bump swap that reuses a layer id with a different source', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const docV1: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [rasterLayer('a', { imageName: 'src-v1' })], + selectedLayerId: 'a', + version: 2, + width: 100, + }; + const { setDocument, store } = createReactiveStore(docV1); + const resolver = vi.fn((_imageName: string) => Promise.resolve(new Blob())); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: resolver, + projectId: 'p1', + store, + }); + + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + + // Initial rasterize of the v1 source. + raf.flush(); + await flushMicrotasks(); + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenNthCalledWith(1, 'src-v1', expect.any(AbortSignal)); + + // A revision bump that REUSES layer id 'a' with a DIFFERENT source: the + // mirror routes this through onDocumentReplaced (a full swap), which a + // reference diff alone could not tell from an ordinary edit. The engine must + // invalidate the surviving cache entry so it re-rasterizes the new source + // (a stale cache entry would keep rendering the v1 pixels). + setDocument({ ...docV1, layers: [rasterLayer('a', { imageName: 'src-v2' })] }, 1); + + // Persistence bookkeeping for the outgoing document was dropped so a reused + // layer id can't have its next legit persistence dispatch suppressed. + expect(bitmapStore.reset).toHaveBeenCalledTimes(1); + + // The invalidated cache re-rasterizes, this time from the v2 source. + raf.flush(); + await flushMicrotasks(); + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(2); + expect(resolver).toHaveBeenNthCalledWith(2, 'src-v2', expect.any(AbortSignal)); + + engine.lifecycle.dispose(); + }); +}); + +describe('engine-owned history: undo/redo guarded during an active gesture', () => { + it('no-ops undo/redo mid-stroke, then works after the gesture ends', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const strokes: StrokeCommittedEvent[] = []; + engine.tools.onStrokeCommitted((event) => strokes.push(event)); + + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('brush'); + + // First, record one committed stroke so there is something to undo. + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + expect(engine.stores.canUndo.get()).toBe(true); + + // Start a SECOND stroke and leave it open (pointer down + move, no up). + // (The live session itself may draw, so snapshot the put count after it opens.) + overlay.fire('pointerdown', pointerAt(50, 50)); + overlay.fire('pointermove', pointerAt(60, 60)); + const putsMidGesture = putImageDataCalls(surfaces).length; + + // Mid-gesture undo/redo must be no-ops: no history pop and no putImageData. + engine.history.undo(); + engine.history.redo(); + expect(engine.stores.canUndo.get()).toBe(true); + expect(putImageDataCalls(surfaces).length).toBe(putsMidGesture); + // In particular, the first stroke's before pixels were never injected. + expect(putImageDataCalls(surfaces).some((call) => call.image === strokes[0]!.beforeImageData)).toBe(false); + + // End the gesture; now undo works and writes the newest stroke's before pixels. + overlay.fire('pointerup', pointerAt(60, 60, { buttons: 0 })); + expect(strokes).toHaveLength(2); + engine.history.undo(); + expect(putImageDataCalls(surfaces).some((call) => call.image === strokes[1]!.beforeImageData)).toBe(true); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(true); + + engine.lifecycle.dispose(); + }); +}); + +// ---- commitStructural: UI-initiated structural edits on the canvas history ---- + +describe('commitStructural', () => { + const forward: EngineTestAction = { id: 'a', type: 'setCanvasSelectedLayer' }; + const inverse: EngineTestAction = { id: null, type: 'setCanvasSelectedLayer' }; + + it('dispatches forward immediately and records a reversible history entry', () => { + const { store } = createFakeStore(makeDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + expect(engine.layers.canCommitStructural()).toBe(true); + expect(engine.layers.commitStructural('Select layer', forward, inverse)).toBe(true); + // Forward dispatched once; the edit is now undoable but not redoable. + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenNthCalledWith(1, forward); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + + // Undo dispatches the inverse and flips the stacks. + engine.history.undo(); + expect(dispatch).toHaveBeenNthCalledWith(2, inverse); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + + // Redo re-dispatches the forward. + engine.history.redo(); + expect(dispatch).toHaveBeenNthCalledWith(3, forward); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.lifecycle.dispose(); + expect(engine.layers.canCommitStructural()).toBe(false); + expect(engine.layers.commitStructural('Select layer', forward, inverse)).toBe(false); + }); +}); + +// ---- drawLayerThumbnail: cache-backed layer previews -------------------- + +const controlLayerForThumbnail = (id: string): CanvasLayerContract => + ({ + adapter: { + beginEndStepPct: [0, 1], + controlMode: null, + kind: 'controlnet', + model: null, + weight: 1, + }, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height: 10, imageName: id, width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: true, + }) as CanvasLayerContract; + +const thumbnailMaskLayer = (id: string, type: 'inpaint_mask' | 'regional_guidance'): CanvasLayerContract => + ({ + autoNegative: true, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { + bitmap: { height: 10, imageName: id, width: 10 }, + fill: { color: '#e07575', style: 'diagonal' }, + }, + name: id, + negativePrompt: null, + opacity: 1, + positivePrompt: null, + referenceImages: [], + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type, + }) as CanvasLayerContract; + +const largeThumbnailLayer = ( + kind: 'raster' | 'control' | 'inpaint_mask' | 'regional_guidance' +): CanvasLayerContract => { + const image = { height: 2048, imageName: `large-${kind}`, width: 4096 }; + if (kind === 'raster') { + return { + ...rasterLayer('a'), + adjustments: { brightness: 0.25, contrast: -0.1, saturation: 0.3 }, + source: { bitmap: image, offset: { x: -300, y: 250 }, type: 'paint' }, + } as CanvasRasterLayerContractV2; + } + if (kind === 'control') { + return { ...controlLayerForThumbnail('a'), source: { image, type: 'image' } } as CanvasLayerContract; + } + const mask = thumbnailMaskLayer('a', kind); + return { + ...mask, + mask: { ...('mask' in mask ? mask.mask : {}), bitmap: image, offset: { x: -300, y: 250 } }, + } as CanvasLayerContract; +}; + +const createSeededThumbnailBackend = (rgba: [number, number, number, number]): StubRasterBackend => { + const backend = createTestStubRasterBackend(); + const createSurface = backend.createSurface.bind(backend); + backend.createSurface = (width, height) => { + const surface = createSurface(width, height); + const originalCtx = surface.ctx; + const ctx = new Proxy(originalCtx, { + get(target, property, receiver) { + if (property === 'getImageData') { + return (x: number, y: number, imageWidth: number, imageHeight: number): ImageData => { + target.getImageData(x, y, imageWidth, imageHeight); + const data = new Uint8ClampedArray(imageWidth * imageHeight * 4); + for (let index = 0; index < data.length; index += 4) { + data.set(rgba, index); + } + return { colorSpace: 'srgb', data, height: imageHeight, width: imageWidth } as ImageData; + }; + } + return Reflect.get(target, property, receiver); + }, + }); + Object.defineProperty(surface, 'ctx', { value: ctx }); + return surface; + }; + return backend; +}; + +const createThumbnailTarget = (): { + calls: { args: unknown[]; op: string }[]; + target: HTMLCanvasElement; +} => { + const calls: { args: unknown[]; op: string }[] = []; + const ctx = { + clearRect: (...args: unknown[]) => calls.push({ args, op: 'clearRect' }), + createPattern: (...args: unknown[]) => { + calls.push({ args, op: 'createPattern' }); + return 'checker-pattern'; + }, + drawImage: (...args: unknown[]) => calls.push({ args, op: 'drawImage' }), + fillRect: (...args: unknown[]) => calls.push({ args, op: 'fillRect' }), + set globalAlpha(value: unknown) { + calls.push({ args: [value], op: 'globalAlpha' }); + }, + set fillStyle(value: unknown) { + calls.push({ args: [value], op: 'fillStyle' }); + }, + }; + const target = { getContext: () => ctx, height: 0, width: 0 } as unknown as HTMLCanvasElement; + return { calls, target }; +}; + +describe('drawLayerThumbnail', () => { + it('returns false and draws nothing when the layer has no cache', () => { + const { engine } = createEngine(); + const { calls, target } = createThumbnailTarget(); + expect(engine.previews.drawLayerThumbnail('missing', target, 96)).toBe(false); + expect(calls).toHaveLength(0); + engine.lifecycle.dispose(); + }); + + it('scales the layer cache into the target and reports success', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(makeDoc()); // one 10x10 image layer 'a' + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + // One frame creates the layer cache entry (10x10). + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + const { calls, target } = createThumbnailTarget(); + expect(engine.previews.drawLayerThumbnail('a', target, 96)).toBe(true); + // 10x10 never upscales, so the target keeps the source dimensions. + expect(target.width).toBe(10); + expect(target.height).toBe(10); + expect(calls.map((call) => call.op)).toEqual([ + 'clearRect', + 'createPattern', + 'fillStyle', + 'fillRect', + 'globalAlpha', + 'drawImage', + ]); + + engine.lifecycle.dispose(); + }); + + it('draws adjusted raster pixels over the checkerboard', async () => { + const layer = { + ...rasterLayer('a'), + adjustments: { brightness: 0.2, contrast: -0.1, saturation: 0.3 }, + opacity: 0.4, + } as CanvasRasterLayerContractV2; + const backend = createSeededThumbnailBackend([10, 20, 30, 255]); + const createSurface = vi.spyOn(backend, 'createSurface'); + const { store } = createReactiveStore({ ...makeDoc(), layers: [layer] }); + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + await engine.previews.requestLayerThumbnail('a'); + const surfaceCountBeforeDraw = createSurface.mock.calls.length; + const adjustedGetCountBeforeDraw = adjustedSurfaceCacheGets.length; + + const { calls, target } = createThumbnailTarget(); + expect(engine.previews.drawLayerThumbnail('a', target, 96)).toBe(true); + expect(adjustedSurfaceCacheGets).toHaveLength(adjustedGetCountBeforeDraw); + expect(createSurface.mock.calls.length).toBeGreaterThan(surfaceCountBeforeDraw); + const createdSurfaces = createSurface.mock.results + .slice(surfaceCountBeforeDraw) + .map((result) => result.value as StubRasterSurface); + expect(createdSurfaces.some((surface) => surface.callLog.some((call) => call.op === 'getImageData'))).toBe(true); + const adjustedPixels = createdSurfaces + .flatMap((surface) => surface.callLog) + .find((call) => call.op === 'putImageData')?.args[0] as ImageData; + expect(Array.from(adjustedPixels.data.slice(0, 4))).toEqual([66, 77, 89, 255]); + const operations = calls.map((call) => call.op); + expect(operations.indexOf('fillRect')).toBeLessThan(operations.lastIndexOf('drawImage')); + expect(calls).toContainEqual({ args: [0.4], op: 'globalAlpha' }); + engine.lifecycle.dispose(); + }); + + it.each([ + ['control transparency', { ...controlLayerForThumbnail('a'), withTransparencyEffect: true }, 'getImageData'], + ['inpaint mask fill', thumbnailMaskLayer('a', 'inpaint_mask'), 'source-in'], + ['regional mask fill', thumbnailMaskLayer('a', 'regional_guidance'), 'source-in'], + ])('renders %s pixels over the checkerboard', async (_name, layer, expectedEffect) => { + const backend = + expectedEffect === 'getImageData' + ? createSeededThumbnailBackend([100, 50, 200, 255]) + : createTestStubRasterBackend(); + const createSurface = vi.spyOn(backend, 'createSurface'); + const { store } = createReactiveStore({ ...makeDoc(), layers: [layer as CanvasLayerContract] }); + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + await engine.previews.requestLayerThumbnail('a'); + const surfaceCountBeforeDraw = createSurface.mock.calls.length; + + const { calls, target } = createThumbnailTarget(); + expect(engine.previews.drawLayerThumbnail('a', target, 96)).toBe(true); + expect(createSurface.mock.calls.length).toBeGreaterThan(surfaceCountBeforeDraw); + const createdSurfaces = createSurface.mock.results + .slice(surfaceCountBeforeDraw) + .map((result) => result.value as StubRasterSurface); + expect( + createdSurfaces.some((surface) => + surface.callLog.some( + (call) => call.op === expectedEffect || (call.op === 'set' && call.args[1] === expectedEffect) + ) + ) + ).toBe(true); + if (expectedEffect === 'getImageData') { + const effectedPixels = createdSurfaces + .flatMap((surface) => surface.callLog) + .find((call) => call.op === 'putImageData')?.args[0] as ImageData; + expect(Array.from(effectedPixels.data.slice(0, 4))).toEqual([100, 50, 200, 125]); + } else { + expect( + createdSurfaces.some((surface) => + surface.callLog.some( + (call) => + call.op === 'set' && + (call.args[0] === 'fillStyle' || call.args[0] === 'strokeStyle') && + call.args[1] === '#e07575' + ) + ) + ).toBe(true); + } + expect(calls.map((call) => call.op)).toContain('fillRect'); + expect(calls.at(-1)?.op).toBe('drawImage'); + engine.lifecycle.dispose(); + }); + + it.each(['raster', 'control', 'inpaint_mask', 'regional_guidance'] as const)( + 'bounds every %s thumbnail effect allocation before processing a large source', + async (kind) => { + const layer = largeThumbnailLayer(kind); + const backend = createTestStubRasterBackend(); + const createSurface = vi.spyOn(backend, 'createSurface'); + const { store } = createReactiveStore({ ...makeDoc(), layers: [layer] }); + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + await engine.previews.requestLayerThumbnail('a'); + createSurface.mockClear(); + + const { target } = createThumbnailTarget(); + expect(engine.previews.drawLayerThumbnail('a', target, 96)).toBe(true); + + expect(target.width).toBe(96); + expect(target.height).toBe(48); + expect(createSurface.mock.calls.length).toBeGreaterThan(0); + expect(createSurface.mock.calls.every(([width, height]) => width <= 96 && height <= 96)).toBe(true); + const scratch = createSurface.mock.results + .map((result) => result.value as StubRasterSurface) + .find((surface) => + surface.callLog.some( + (call) => + call.op === 'drawImage' && + call.args[1] === 0 && + call.args[2] === 0 && + call.args[3] === 96 && + call.args[4] === 48 + ) + ); + expect(scratch).toBeDefined(); + engine.lifecycle.dispose(); + } + ); + + it.each([ + [ + 'raster adjustments', + rasterLayer('a'), + (layer: CanvasLayerContract) => ({ + ...layer, + adjustments: { brightness: 0.25, contrast: 0, saturation: 0 }, + }), + ], + [ + 'control transparency', + controlLayerForThumbnail('a'), + (layer: CanvasLayerContract) => ({ ...layer, withTransparencyEffect: false }), + ], + [ + 'inpaint mask fill', + thumbnailMaskLayer('a', 'inpaint_mask'), + (layer: CanvasLayerContract) => ({ + ...layer, + mask: { ...('mask' in layer ? layer.mask : {}), fill: { color: '#00ff00', style: 'solid' } }, + }), + ], + [ + 'regional mask fill', + thumbnailMaskLayer('a', 'regional_guidance'), + (layer: CanvasLayerContract) => ({ + ...layer, + mask: { ...('mask' in layer ? layer.mask : {}), fill: { color: '#00ff00', style: 'solid' } }, + }), + ], + ['opacity', rasterLayer('a'), (layer: CanvasLayerContract) => ({ ...layer, opacity: 0.5 })], + ])( + 'invalidates the keyed thumbnail version when %s changes without changing the source', + async (_name, layer, edit) => { + const doc = { ...makeDoc(), layers: [layer as CanvasLayerContract] }; + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + await engine.previews.requestLayerThumbnail('a'); + const listener = vi.fn(); + const unsubscribe = engine.stores.thumbnailVersion.subscribeKey('a', listener); + + setDocument({ ...doc, layers: [edit(layer as CanvasLayerContract) as CanvasLayerContract] }); + + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + engine.lifecycle.dispose(); + } + ); + + it('does not let display invalidation suppress the next cache-version publication', async () => { + const layer = rasterLayer('a'); + const doc = { ...makeDoc(), layers: [layer] }; + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + await engine.previews.requestLayerThumbnail('a'); + const listener = vi.fn(); + const unsubscribe = engine.stores.thumbnailVersion.subscribeKey('a', listener); + + const adjusted = { + ...layer, + adjustments: { brightness: 0.25, contrast: 0, saturation: 0 }, + } as CanvasLayerContract; + setDocument({ ...doc, layers: [adjusted] }); + setDocument({ ...doc, layers: [rasterLayer('a', { imageName: 'a-v2' })] }); + await engine.previews.requestLayerThumbnail('a'); + + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + engine.lifecycle.dispose(); + }); + + it('refuses a newly allocated cache until pixels have been published', () => { + const pending = createDeferred(); + const { store } = createReactiveStore(makeDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => pending.promise, + projectId: 'p1', + store, + }); + const { calls, target } = createThumbnailTarget(); + + void engine.previews.requestLayerThumbnail('a'); + + expect(engine.previews.drawLayerThumbnail('a', target, 96)).toBe(false); + expect(calls).toHaveLength(0); + engine.lifecycle.dispose(); + }); +}); + +describe('requestLayerThumbnail', () => { + it('starts a request for its bound project while another project is active', async () => { + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const { setActiveProjectId, store } = createReactiveStore(makeDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + setActiveProjectId('p2'); + + expect(await engine.previews.requestLayerThumbnail('a')).toBe('ready'); + expect(resolver).toHaveBeenCalledOnce(); + expect(engine.stores.thumbnailStatus.get('a')).toBe('ready'); + engine.lifecycle.dispose(); + }); + + it('rasterizes while detached and publishes thumbnail readiness', async () => { + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const { store } = createReactiveStore(makeDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + + expect(engine.stores.thumbnailStatus.get('a')).toBeUndefined(); + expect(await engine.previews.requestLayerThumbnail('a')).toBe('ready'); + expect(resolver).toHaveBeenCalledTimes(1); + expect(engine.stores.thumbnailStatus.get('a')).toBe('ready'); + expect(engine.previews.drawLayerThumbnail('a', createThumbnailTarget().target, 96)).toBe(true); + engine.lifecycle.dispose(); + }); + + it('rasterizes disabled layers on explicit request', async () => { + const layer = { ...makeDoc().layers[0]!, isEnabled: false }; + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const { store } = createReactiveStore({ ...makeDoc(), layers: [layer] }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + + expect(await engine.previews.requestLayerThumbnail('a')).toBe('ready'); + expect(resolver).toHaveBeenCalledTimes(1); + engine.lifecycle.dispose(); + }); + + it('releases a detached thumbnail bitmap when its layer source is replaced', async () => { + const bitmap = recordingBitmap('detached-replaced'); + const backend = createTestStubRasterBackend(); + backend.createImageBitmap = vi.fn(() => Promise.resolve(bitmap)); + const doc = { ...makeDoc(), layers: [rasterLayer('a', { imageName: 'old' })] }; + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + expect(await engine.previews.requestLayerThumbnail('a')).toBe('ready'); + setDocument({ ...doc, layers: [rasterLayer('a', { imageName: 'new' })] }); + + expect(bitmap.close).toHaveBeenCalledTimes(1); + engine.lifecycle.dispose(); + }); + + it('releases a detached thumbnail bitmap when its layer is deleted', async () => { + const bitmap = recordingBitmap('detached-deleted'); + const backend = createTestStubRasterBackend(); + backend.createImageBitmap = vi.fn(() => Promise.resolve(bitmap)); + const doc = { ...makeDoc(), layers: [rasterLayer('a', { imageName: 'old' })] }; + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + expect(await engine.previews.requestLayerThumbnail('a')).toBe('ready'); + setDocument({ ...doc, layers: [] }); + + expect(bitmap.close).toHaveBeenCalledTimes(1); + engine.lifecycle.dispose(); + }); + + it('releases each sequential shared-source decode after its thumbnail consumer settles', async () => { + const bitmap = recordingBitmap('shared-deleted'); + const backend = createTestStubRasterBackend(); + backend.createImageBitmap = vi.fn(() => Promise.resolve(bitmap)); + const first = rasterLayer('a', { imageName: 'shared' }); + const second = rasterLayer('b', { imageName: 'shared' }); + const doc = { ...makeDoc(), layers: [first, second] }; + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect(await engine.previews.requestLayerThumbnail('a')).toBe('ready'); + expect(await engine.previews.requestLayerThumbnail('b')).toBe('ready'); + expect(bitmap.close).toHaveBeenCalledTimes(2); + + setDocument({ ...doc, layers: [second] }); + setDocument({ ...doc, layers: [] }); + + expect(bitmap.close).toHaveBeenCalledTimes(2); + engine.lifecycle.dispose(); + }); + + it('does not retain settled shared-source decodes until layers change source', async () => { + const bitmap = recordingBitmap('shared-replaced'); + const backend = createTestStubRasterBackend(); + backend.createImageBitmap = vi.fn(() => Promise.resolve(bitmap)); + const first = rasterLayer('a', { imageName: 'shared' }); + const second = rasterLayer('b', { imageName: 'shared' }); + const doc = { ...makeDoc(), layers: [first, second] }; + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect(await engine.previews.requestLayerThumbnail('a')).toBe('ready'); + expect(await engine.previews.requestLayerThumbnail('b')).toBe('ready'); + expect(bitmap.close).toHaveBeenCalledTimes(2); + + const firstChanged = rasterLayer('a', { imageName: 'first-new' }); + setDocument({ ...doc, layers: [firstChanged, second] }); + setDocument({ ...doc, layers: [firstChanged, rasterLayer('b', { imageName: 'second-new' })] }); + + expect(bitmap.close).toHaveBeenCalledTimes(2); + engine.lifecycle.dispose(); + }); + + it('releases a decoded bitmap when invalidation lands after cache insertion but before publication', async () => { + const bitmap = recordingBitmap('stale-before-publication'); + const original = rasterLayer('a', { imageName: 'old' }); + const doc = { ...makeDoc(), layers: [original] }; + const { setDocument, store } = createReactiveStore(doc); + const backend = createInvalidateDuringBitmapDrawBackend(bitmap, () => { + setDocument({ ...doc, layers: [rasterLayer('a', { imageName: 'new' })] }); + }); + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + expect(await engine.previews.requestLayerThumbnail('a')).toBe('stale'); + expect(bitmap.close).toHaveBeenCalledTimes(1); + engine.lifecycle.dispose(); + }); + + it('releases a stale decode immediately even when another layer references its source', async () => { + const bitmap = recordingBitmap('stale-shared-before-publication'); + const first = rasterLayer('a', { imageName: 'shared' }); + const second = rasterLayer('b', { imageName: 'shared' }); + const doc = { ...makeDoc(), layers: [first, second] }; + const { setDocument, store } = createReactiveStore(doc); + const backend = createInvalidateDuringBitmapDrawBackend(bitmap, () => { + setDocument({ ...doc, layers: [rasterLayer('a', { imageName: 'new' }), second] }); + }); + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + expect(await engine.previews.requestLayerThumbnail('a')).toBe('stale'); + expect(bitmap.close).toHaveBeenCalledTimes(1); + setDocument({ ...doc, layers: [rasterLayer('a', { imageName: 'new' })] }); + expect(bitmap.close).toHaveBeenCalledTimes(1); + engine.lifecycle.dispose(); + }); + + it('releases a stale decode without waiting for a matching paint source to change', async () => { + const bitmap = recordingBitmap('stale-shared-paint'); + const first = rasterLayer('a', { imageName: 'shared' }); + const second: CanvasRasterLayerContractV2 = { + ...(rasterLayer('b') as CanvasRasterLayerContractV2), + source: { bitmap: { height: 10, imageName: 'shared', width: 10 }, type: 'paint' }, + }; + const doc = { ...makeDoc(), layers: [first, second] }; + const { setDocument, store } = createReactiveStore(doc); + const backend = createInvalidateDuringBitmapDrawBackend(bitmap, () => { + setDocument({ ...doc, layers: [second] }); + }); + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + expect(await engine.previews.requestLayerThumbnail('a')).toBe('stale'); + expect(bitmap.close).toHaveBeenCalledTimes(1); + setDocument({ + ...doc, + layers: [{ ...second, source: { bitmap: { height: 10, imageName: 'new', width: 10 }, type: 'paint' } }], + }); + expect(bitmap.close).toHaveBeenCalledTimes(1); + engine.lifecycle.dispose(); + }); + + it('releases a stale decode without waiting for a matching mask bitmap to change', async () => { + const bitmap = recordingBitmap('stale-shared-mask'); + const first = rasterLayer('a', { imageName: 'shared' }); + const second: CanvasInpaintMaskLayerContract = { + ...maskLayer('b'), + mask: { + ...maskLayer('b').mask, + bitmap: { height: 10, imageName: 'shared', width: 10 }, + }, + }; + const doc = { ...makeDoc(), layers: [first, second] }; + const { setDocument, store } = createReactiveStore(doc); + const backend = createInvalidateDuringBitmapDrawBackend(bitmap, () => { + setDocument({ ...doc, layers: [second] }); + }); + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + expect(await engine.previews.requestLayerThumbnail('a')).toBe('stale'); + expect(bitmap.close).toHaveBeenCalledTimes(1); + setDocument({ + ...doc, + layers: [ + { + ...second, + mask: { ...second.mask, bitmap: { height: 10, imageName: 'new', width: 10 } }, + }, + ], + }); + expect(bitmap.close).toHaveBeenCalledTimes(1); + engine.lifecycle.dispose(); + }); + + it('releases a finished unpublished decode while another request is still in flight', async () => { + const bitmap = recordingBitmap('stale-shared-in-flight'); + const secondResolve = createDeferred(); + let resolveCount = 0; + const resolver = vi.fn(() => { + resolveCount += 1; + return resolveCount === 1 ? Promise.resolve(new Blob()) : secondResolve.promise; + }); + const first = rasterLayer('a', { imageName: 'shared' }); + const second = rasterLayer('b', { imageName: 'shared' }); + const doc = { ...makeDoc(), layers: [first, second] }; + const { setDocument, store } = createReactiveStore(doc); + const backend = createInvalidateDuringBitmapDrawBackend(bitmap, () => { + setDocument({ + ...doc, + layers: [rasterLayer('a', { imageName: 'first-new' }), rasterLayer('b', { imageName: 'second-new' })], + }); + }); + const engine = createCanvasEngine({ backend, imageResolver: resolver, projectId: 'p1', store }); + + const firstRequest = engine.previews.requestLayerThumbnail('a'); + const secondRequest = engine.previews.requestLayerThumbnail('b'); + expect(await firstRequest).toBe('stale'); + expect(bitmap.close).toHaveBeenCalledTimes(1); + + secondResolve.resolve(new Blob()); + expect(await secondRequest).toBe('stale'); + expect(bitmap.close).toHaveBeenCalledTimes(1); + engine.lifecycle.dispose(); + }); + + it('deduplicates concurrent requests for the same source version', async () => { + const pending = createDeferred(); + const resolver = vi.fn(() => pending.promise); + const { store } = createReactiveStore(makeDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + + const first = engine.previews.requestLayerThumbnail('a'); + const second = engine.previews.requestLayerThumbnail('a'); + expect(resolver).toHaveBeenCalledTimes(1); + + pending.resolve(new Blob()); + expect(await Promise.all([first, second])).toEqual(['ready', 'ready']); + engine.lifecycle.dispose(); + }); + + it('keeps loading until the latest source wins', async () => { + const { requests, resolver } = createAbortableImageResolver(); + const doc = makeDoc(); + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + + const oldRequest = engine.previews.requestLayerThumbnail('a'); + setDocument({ ...doc, layers: [rasterLayer('a', { imageName: 'a-v2' })] }); + expect(requests.get('a')?.signal?.aborted).toBe(true); + const newRequest = engine.previews.requestLayerThumbnail('a'); + + expect(await oldRequest).toBe('stale'); + expect(engine.stores.thumbnailStatus.get('a')).toBe('loading'); + + requests.get('a-v2')?.deferred.resolve(new Blob()); + expect(await newRequest).toBe('ready'); + expect(engine.stores.thumbnailStatus.get('a')).toBe('ready'); + engine.lifecycle.dispose(); + }); + + it('reports failures through status and diagnostics, then retries', async () => { + const resolver = vi.fn().mockRejectedValueOnce(new Error('decode failed')).mockResolvedValueOnce(new Blob()); + const { store } = createReactiveStore(makeDoc()); + const reportError = vi.fn(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + reportError, + store, + }); + + expect(await engine.previews.requestLayerThumbnail('a')).toBe('error'); + expect(engine.stores.thumbnailStatus.get('a')).toBe('error'); + expect(reportError).toHaveBeenCalledWith( + expect.objectContaining({ + area: 'canvas-engine', + context: expect.objectContaining({ error: 'decode failed', layerId: 'a' }), + message: 'Layer thumbnail rasterization failed', + namespace: 'canvas', + projectId: 'p1', + }) + ); + + const retry = engine.previews.requestLayerThumbnail('a'); + expect(engine.stores.thumbnailStatus.get('a')).toBe('loading'); + expect(await retry).toBe('ready'); + expect(resolver).toHaveBeenCalledTimes(2); + engine.lifecycle.dispose(); + }); + + it('clears state and rejects stale completion after deletion', async () => { + const { requests, resolver } = createAbortableImageResolver(); + const doc = makeDoc(); + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + + const request = engine.previews.requestLayerThumbnail('a'); + expect(engine.stores.thumbnailStatus.get('a')).toBe('loading'); + setDocument({ ...doc, layers: [] }); + expect(requests.get('a')?.signal?.aborted).toBe(true); + expect(engine.stores.thumbnailStatus.get('a')).toBeUndefined(); + + expect(await request).toBe('stale'); + expect(engine.stores.thumbnailStatus.get('a')).toBeUndefined(); + expect(engine.previews.drawLayerThumbnail('a', createThumbnailTarget().target, 96)).toBe(false); + engine.lifecycle.dispose(); + }); + + it('clears state when the document is replaced', async () => { + const { requests, resolver } = createAbortableImageResolver(); + const doc = makeDoc(); + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + + const request = engine.previews.requestLayerThumbnail('a'); + setDocument({ ...doc, width: doc.width + 1 }, 1); + + expect(requests.get('a')?.signal?.aborted).toBe(true); + expect(await request).toBe('stale'); + expect(engine.stores.thumbnailStatus.get('a')).toBeUndefined(); + engine.lifecycle.dispose(); + }); + + it('keeps an in-flight thumbnail request after another project becomes active', async () => { + const pending = createDeferred(); + let signal: AbortSignal | undefined; + const resolver = vi.fn((_imageName: string, nextSignal?: AbortSignal) => { + signal = nextSignal; + return pending.promise; + }); + const { setActiveProjectId, store } = createReactiveStore(makeDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + + const request = engine.previews.requestLayerThumbnail('a'); + setActiveProjectId('p2'); + expect(signal?.aborted).toBe(false); + expect(engine.stores.thumbnailStatus.get('a')).toBe('loading'); + + pending.resolve(new Blob()); + expect(await request).toBe('ready'); + expect(engine.stores.thumbnailStatus.get('a')).toBe('ready'); + expect(engine.previews.drawLayerThumbnail('a', createThumbnailTarget().target, 96)).toBe(true); + engine.lifecycle.dispose(); + }); + + it('clears state and rejects stale completion after disposal', async () => { + const { requests, resolver } = createAbortableImageResolver(); + const { store } = createReactiveStore(makeDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + + const request = engine.previews.requestLayerThumbnail('a'); + engine.lifecycle.dispose(); + expect(requests.get('a')?.signal?.aborted).toBe(true); + expect(engine.stores.thumbnailStatus.get('a')).toBeUndefined(); + + expect(await request).toBe('stale'); + expect(engine.stores.thumbnailStatus.get('a')).toBeUndefined(); + }); +}); + +// ---- mergeLayerDown: composites the upper cache into the below local space ---- + +const twoPaintDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'multiply', + id: 'upper', + isEnabled: true, + isLocked: false, + name: 'upper', + opacity: 0.5, + // Content-sized: a persisted bitmap gives the cache a non-empty content rect. + // The upper (40×40) sits fully within the below (60×60) in below-local space, + // so the merge union stays 60×60 and the warped upper transform is non-trivial. + source: { bitmap: { height: 40, imageName: 'upper-bmp', width: 40 }, offset: { x: 0, y: 0 }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 30, y: 40 }, + type: 'raster', + }, + { + blendMode: 'normal', + id: 'below', + isEnabled: true, + isLocked: false, + name: 'below', + opacity: 1, + // Offset so the merge matrix is non-trivial (below-local origin shifts). + source: { bitmap: { height: 60, imageName: 'below-bmp', width: 60 }, offset: { x: 0, y: 0 }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 }, + type: 'raster', + }, + ], + selectedLayerId: 'upper', + version: 2, + width: 100, +}); + +/** An empty inpaint mask layer, for the merge-down mask-rejection tests below. */ +const maskLayer = (id: string): CanvasInpaintMaskLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + name: id, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +describe('mergeLayerDown', () => { + it('composites below then the transformed upper cache, and dispatches mergeCanvasLayersDown', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(twoPaintDoc()); + const dispatch = store.dispatch as Mock; + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + // One frame builds both layer caches; await the async bitmap decode so both + // caches are READY (merge refuses stale/in-flight caches — finding 20). + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + const surfacesBeforeMerge = surfaces.length; + expect(engine.layers.mergeLayerDown('upper')).toBe(true); + + // The reducer is asked to collapse the two layers into a paint layer. + const mergeCall = dispatch.mock.calls + .map((call) => call[0] as EngineTestAction) + .find((action) => action.type === 'mergeCanvasLayersDown'); + expect(mergeCall).toEqual({ + source: { bitmap: null, offset: { x: 0, y: 0 }, type: 'paint' }, + type: 'mergeCanvasLayersDown', + upperLayerId: 'upper', + }); + + // A fresh union-sized surface was allocated for the merged pixels. Content- + // sized: below's rect {0,0,60,60} unioned with upper's rect warped into + // below-local space {20,20,40,40} is {0,0,60,60}, so the merged surface stays + // 60×60 with origin (0,0). + const merged = surfaces[surfacesBeforeMerge]; + expect(merged).toBeDefined(); + expect(merged!.width).toBe(60); + expect(merged!.height).toBe(60); + const log = merged!.callLog; + + const expectedMatrix = mergeDownMatrix( + { rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 }, + { rotation: 0, scaleX: 1, scaleY: 1, x: 30, y: 40 } + )!; + // upper{30,40} → below-local via inverse(below{10,20}) = translate(20,20). + expect(expectedMatrix.e).toBe(20); + expect(expectedMatrix.f).toBe(20); + const mergedOrigin = { x: 0, y: 0 }; + const matrixIndex = log.findIndex( + (entry) => + entry.op === 'setTransform' && + entry.args[0] === expectedMatrix.a && + entry.args[4] === expectedMatrix.e - mergedOrigin.x && + entry.args[5] === expectedMatrix.f - mergedOrigin.y + ); + expect(matrixIndex).toBeGreaterThan(-1); + + // Below is blitted before the upper transform; the upper after it. + const drawIndices = log.reduce((acc, entry, index) => { + if (entry.op === 'drawImage') { + acc.push(index); + } + return acc; + }, []); + expect(drawIndices).toHaveLength(2); + expect(drawIndices[0]).toBeLessThan(matrixIndex); + expect(drawIndices[1]).toBeGreaterThan(matrixIndex); + + // The upper layer's opacity and blend mode are baked into the merge. + const alphaSet = log.find( + (entry) => entry.op === 'set' && entry.args[0] === 'globalAlpha' && entry.args[1] === 0.5 + ); + const blendSet = log.find( + (entry) => entry.op === 'set' && entry.args[0] === 'globalCompositeOperation' && entry.args[1] === 'multiply' + ); + expect(alphaSet).toBeDefined(); + expect(blendSet).toBeDefined(); + + engine.lifecycle.dispose(); + }); + + it('is a no-op for the bottom-most layer (nothing below)', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(twoPaintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + + expect(engine.layers.mergeLayerDown('below')).toBe(false); + expect(dispatch.mock.calls.some((call) => (call[0] as EngineTestAction).type === 'mergeCanvasLayersDown')).toBe( + false + ); + + engine.lifecycle.dispose(); + }); + + it.each(['upper', 'below'] as const)( + "is a no-op when the %s layer is locked (merge is not undoable; must mirror the paint tool's locked-target refusal)", + (lockedId) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const doc = twoPaintDoc(); + const locked = { + ...doc, + layers: doc.layers.map((layer) => (layer.id === lockedId ? { ...layer, isLocked: true } : layer)), + }; + const { store } = createReactiveStore(locked); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + + expect(engine.layers.mergeLayerDown('upper')).toBe(false); + expect(dispatch.mock.calls.some((call) => (call[0] as EngineTestAction).type === 'mergeCanvasLayersDown')).toBe( + false + ); + + engine.lifecycle.dispose(); + } + ); + + // A two-paint doc where exactly ONE layer is content-empty (bitmap: null → a 0×0 + // cache surface). Merging must NOT `drawImage` the zero-dimension operand (which + // throws in browsers) but must still dispatch the collapse and composite the + // non-empty operand. + const oneEmptyPaintDoc = (emptyId: 'upper' | 'below'): CanvasDocumentContractV2 => { + const doc = twoPaintDoc(); + return { + ...doc, + layers: doc.layers.map((layer) => + layer.id === emptyId ? { ...layer, source: { bitmap: null, offset: { x: 0, y: 0 }, type: 'paint' } } : layer + ), + }; + }; + + it.each([ + { emptyId: 'upper', keptId: 'below' }, + { emptyId: 'below', keptId: 'upper' }, + ] as const)( + 'merges when only the $emptyId layer is empty: dispatches, and never draws a 0×0 surface', + async ({ emptyId }) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(oneEmptyPaintDoc(emptyId)); + const dispatch = store.dispatch as Mock; + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + // Await the kept layer's bitmap decode so its cache is READY before merge + // (the empty operand needs no decode; merge refuses stale/in-flight caches). + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + const surfacesBeforeMerge = surfaces.length; + expect(engine.layers.mergeLayerDown('upper')).toBe(true); + + // Single-dispatch collapse still happens (undo semantics unchanged). + expect(dispatch.mock.calls.some((call) => (call[0] as EngineTestAction).type === 'mergeCanvasLayersDown')).toBe( + true + ); + + // The merged surface only composites the NON-empty operand: exactly one + // drawImage, and it never draws a zero-dimension source canvas. + const merged = surfaces[surfacesBeforeMerge]; + expect(merged).toBeDefined(); + const draws = merged!.callLog.filter((entry) => entry.op === 'drawImage'); + expect(draws).toHaveLength(1); + for (const draw of draws) { + const src = draw.args[0] as { width: number; height: number }; + expect(src.width).toBeGreaterThan(0); + expect(src.height).toBeGreaterThan(0); + } + + engine.lifecycle.dispose(); + } + ); + + // A both-empty pair must fold trivially (delete the upper, below stays empty) + // rather than silently no-op — otherwise merge-visible stalls on such a run (F4). + it('folds a both-empty pair trivially: dispatches the collapse and allocates no merged surface (F4)', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const base = twoPaintDoc(); + const doc: CanvasDocumentContractV2 = { + ...base, + layers: base.layers.map((layer) => ({ + ...layer, + source: { bitmap: null, offset: { x: 0, y: 0 }, type: 'paint' as const }, + })), + }; + const { store } = createReactiveStore(doc); + const dispatch = store.dispatch as Mock; + const backendBase = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...backendBase, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = backendBase.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + const surfacesBeforeMerge = surfaces.length; + expect(engine.layers.mergeLayerDown('upper')).toBe(true); + + // The collapse still dispatches (the upper layer is removed). + expect(dispatch.mock.calls.some((call) => (call[0] as EngineTestAction).type === 'mergeCanvasLayersDown')).toBe( + true + ); + // No merged surface was allocated: a 0×0 union surface would throw, and there + // are no pixels to composite. + expect(surfaces.length).toBe(surfacesBeforeMerge); + + engine.lifecycle.dispose(); + }); + + // Regression: `mergeLayerDown` used to gate on `isRenderableLayer`, which masks + // satisfy (a mask rasterizes to a stencil whenever enabled). That let a mask + // reach the merge path, whose reducer unconditionally produces a `type: 'raster'` + // result — merging a mask blitted its stencil into the layer below and/or + // clobbered a mask below into a raster layer, destroying its config, with no + // undo. The guard must reject a mask on EITHER side, mirroring the layers + // panel's `isMergeableRasterLayer`/`canMergeLayerDown` enablement exactly. + it.each([ + { label: 'mask above a raster layer', maskId: 'upper' as const }, + { label: 'a raster layer above a mask', maskId: 'below' as const }, + ])('is a no-op for $label (document unchanged, no dispatch)', ({ maskId }) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const base = twoPaintDoc(); + const doc: CanvasDocumentContractV2 = { + ...base, + layers: base.layers.map((layer) => (layer.id === maskId ? maskLayer(maskId) : layer)), + }; + const { store } = createReactiveStore(doc); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + + expect(engine.layers.mergeLayerDown('upper')).toBe(false); + expect(dispatch.mock.calls.some((call) => (call[0] as EngineTestAction).type === 'mergeCanvasLayersDown')).toBe( + false + ); + // Document unchanged: still two layers, each with its original type — no + // mask was blitted into, and no mask was clobbered into a raster layer. + expect(engine.document.getDocument()!.layers.map((l) => l.type)).toEqual(doc.layers.map((l) => l.type)); + + engine.lifecycle.dispose(); + }); + + it('is a no-op for mask-above-mask (document unchanged, no dispatch)', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const base = twoPaintDoc(); + const doc: CanvasDocumentContractV2 = { + ...base, + layers: [maskLayer('upper'), maskLayer('below')], + }; + const { store } = createReactiveStore(doc); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + + expect(engine.layers.mergeLayerDown('upper')).toBe(false); + expect(dispatch.mock.calls.some((call) => (call[0] as EngineTestAction).type === 'mergeCanvasLayersDown')).toBe( + false + ); + expect(engine.document.getDocument()!.layers.map((l) => l.type)).toEqual(['inpaint_mask', 'inpaint_mask']); + + engine.lifecycle.dispose(); + }); +}); + +describe('boolean raster operations', () => { + const setup = () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const { projectId, store } = createReducerBackedStore(twoPaintDoc()); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (width: number, height: number): StubRasterSurface => { + const surface = base.createSurface(width, height); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + return { engine, raf, surfaces }; + }; + + it.each([ + ['intersect', 'source-in'], + ['cutout', 'destination-in'], + ['cutaway', 'source-out'], + ['exclude', 'xor'], + ] as const)('applies %s with %s, preserves its sources, and supports undo/redo', async (operation, composite) => { + const { engine, raf, surfaces } = setup(); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + expect(await engine.layers.booleanMergeRasterLayers('upper', operation)).toBe('merged'); + + const resultSurface = surfaces.find((surface) => + surface.callLog.some( + (entry) => entry.op === 'set' && entry.args[0] === 'globalCompositeOperation' && entry.args[1] === composite + ) + ); + expect(resultSurface).toBeDefined(); + expect(resultSurface!.callLog.filter((entry) => entry.op === 'drawImage')).toHaveLength(2); + + const merged = engine.document.getDocument()!; + const result = merged.layers.find((layer) => layer.id !== 'upper' && layer.id !== 'below'); + expect(result).toMatchObject({ + isEnabled: true, + source: { bitmap: null, offset: { x: 10, y: 20 }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }); + expect(merged.layers.find((layer) => layer.id === 'upper')?.isEnabled).toBe(false); + expect(merged.layers.find((layer) => layer.id === 'below')?.isEnabled).toBe(false); + + engine.history.undo(); + expect(engine.document.getDocument()!.layers.map((layer) => [layer.id, layer.isEnabled])).toEqual([ + ['upper', true], + ['below', true], + ]); + + engine.history.redo(); + expect(engine.document.getDocument()!.layers.map((layer) => [layer.id, layer.isEnabled])).toEqual([ + [result!.id, true], + ['upper', false], + ['below', false], + ]); + engine.lifecycle.dispose(); + }); + + it('publishes a boolean merge into its bound project after another project becomes active', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const { setActiveProjectId, store } = createReactiveStore(twoPaintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + (store.dispatch as Mock).mockClear(); + + const merge = engine.layers.booleanMergeRasterLayers('upper', 'intersect'); + setActiveProjectId('p2'); + + expect(await merge).toBe('merged'); + expect(store.dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'applyCanvasLayerStackMutation' })); + engine.lifecycle.dispose(); + }); + + it('refuses the operation until both raster caches are ready', async () => { + const { engine, raf } = setup(); + raf.flush(); + + expect(await engine.layers.booleanMergeRasterLayers('upper', 'intersect')).toBe('not-ready'); + expect(engine.document.getDocument()!.layers.map((layer) => layer.id)).toEqual(['upper', 'below']); + engine.lifecycle.dispose(); + }); + + it('rejects unsupported and missing layer pairs without modifying the document', async () => { + const doc = twoPaintDoc(); + doc.layers[1] = maskLayer('below'); + const { projectId, store } = createReducerBackedStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + + expect(await engine.layers.booleanMergeRasterLayers('upper', 'exclude')).toBe('unsupported'); + expect(await engine.layers.booleanMergeRasterLayers('missing', 'exclude')).toBe('missing'); + expect(engine.document.getDocument()!.layers).toEqual(doc.layers); + engine.lifecycle.dispose(); + }); + + it('merges two live unflushed paint caches whose contract bitmaps are still null', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + const emptyPaint = (id: string): CanvasRasterLayerContractV2 => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }); + const upper = emptyPaint('upper'); + const below = emptyPaint('below'); + const doc = { ...makeDoc(), layers: [upper, below], selectedLayerId: 'upper' }; + const { projectId, store } = createReducerBackedStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('brush'); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + overlay.fire('pointerdown', pointerAt(10, 10)); + overlay.fire('pointermove', pointerAt(30, 30)); + overlay.fire('pointerup', pointerAt(30, 30, { buttons: 0 })); + store.dispatch({ id: 'below', type: 'setCanvasSelectedLayer' }); + overlay.fire('pointerdown', pointerAt(15, 15)); + overlay.fire('pointermove', pointerAt(35, 35)); + overlay.fire('pointerup', pointerAt(35, 35, { buttons: 0 })); + + expect(await engine.layers.booleanMergeRasterLayers('upper', 'intersect')).toBe('merged'); + engine.lifecycle.dispose(); + }); +}); + +describe('extract masked canvas area', () => { + const maskedDoc = (): CanvasDocumentContractV2 => { + const doc = twoPaintDoc(); + return { + ...doc, + layers: [ + { + ...maskLayer('mask'), + mask: { + bitmap: { height: 20, imageName: 'mask-bitmap', width: 20 }, + fill: { color: '#e07575', style: 'diagonal' }, + offset: { x: 15, y: 25 }, + }, + }, + ...doc.layers, + ], + selectedLayerId: 'mask', + }; + }; + + const setup = () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const { projectId, store } = createReducerBackedStore(maskedDoc()); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (width: number, height: number): StubRasterSurface => { + const surface = base.createSurface(width, height); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + return { engine, raf, surfaces }; + }; + + it('composites visible raster content through mask alpha and inserts one undoable raster layer', async () => { + const { engine, raf, surfaces } = setup(); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + const result = await engine.exports.extractMaskedArea('mask'); + expect(result.status).toBe('extracted'); + if (result.status !== 'extracted') { + throw new Error('expected an extracted layer'); + } + + const extractedPixels = surfaces.find((surface) => + surface.callLog.some( + (entry) => + entry.op === 'set' && entry.args[0] === 'globalCompositeOperation' && entry.args[1] === 'destination-in' + ) + ); + expect(extractedPixels).toMatchObject({ height: 20, width: 20 }); + expect(extractedPixels!.callLog.filter((entry) => entry.op === 'drawImage').length).toBeGreaterThanOrEqual(3); + + const extracted = engine.document.getDocument()!.layers.find((layer) => layer.id === result.layerId); + expect(extracted).toMatchObject({ + isEnabled: true, + source: { bitmap: null, offset: { x: 15, y: 25 }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }); + expect(engine.document.getDocument()!.layers.find((layer) => layer.id === 'mask')).toEqual(maskedDoc().layers[0]); + expect(engine.document.getDocument()!.layers.find((layer) => layer.id === 'upper')?.isEnabled).toBe(true); + expect(engine.document.getDocument()!.layers.find((layer) => layer.id === 'below')?.isEnabled).toBe(true); + + engine.history.undo(); + expect(engine.document.getDocument()!.layers.some((layer) => layer.id === result.layerId)).toBe(false); + engine.history.redo(); + expect(engine.document.getDocument()!.layers.some((layer) => layer.id === result.layerId)).toBe(true); + engine.lifecycle.dispose(); + }); + + it('extracts enabled raster layers but never control layers', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const raster = rasterLayer('raster'); + const control: CanvasLayerContract = { + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + blendMode: 'normal', + id: 'control', + isEnabled: true, + isLocked: false, + name: 'Control', + opacity: 1, + source: { image: { height: 10, imageName: 'control', width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: false, + }; + const mask: CanvasInpaintMaskLayerContract = { + ...maskLayer('mask'), + mask: { + ...maskLayer('mask').mask, + bitmap: { height: 10, imageName: 'mask', width: 10 }, + }, + }; + const document = { ...makeDoc(), layers: [mask, raster, control], selectedLayerId: 'mask' }; + const { projectId, store } = createReducerBackedStore(document); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const engine = createCanvasEngine({ + backend: { + ...base, + createSurface: (width, height) => { + const surface = base.createSurface(width, height); + surfaces.push(surface); + return surface; + }, + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + const rasterExport = await engine.exports.exportLayerPixels(raster.id); + const controlExport = await engine.exports.exportLayerPixels(control.id); + expect(rasterExport.status).toBe('ok'); + expect(controlExport.status).toBe('ok'); + if (rasterExport.status !== 'ok' || controlExport.status !== 'ok') { + throw new Error('fixture layers did not rasterize'); + } + + expect((await engine.exports.extractMaskedArea(mask.id)).status).toBe('extracted'); + + const extractedPixels = surfaces.find((surface) => + surface.callLog.some( + (entry) => + entry.op === 'set' && entry.args[0] === 'globalCompositeOperation' && entry.args[1] === 'destination-in' + ) + ); + const compositeSources = extractedPixels?.callLog + .filter((entry) => entry.op === 'drawImage') + .map((entry) => entry.args[0]); + expect(compositeSources).toContain(rasterExport.surface.canvas); + expect(compositeSources).not.toContain(controlExport.surface.canvas); + engine.lifecycle.dispose(); + }); + + it('skips enabled raster layers that have no persisted or live pixels', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const document = maskedDoc(); + const blank: CanvasRasterLayerContractV2 = { + blendMode: 'normal', + id: 'blank', + isEnabled: true, + isLocked: false, + name: 'Blank', + opacity: 1, + source: { bitmap: null, offset: { x: 0, y: 0 }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }; + document.layers.splice(2, 0, blank); + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + expect((await engine.exports.extractMaskedArea('mask')).status).toBe('extracted'); + engine.lifecycle.dispose(); + }); + + it('does not extract stale contributor pixels after a source change during await', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const document = maskedDoc(); + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + (store.dispatch as Mock).mockClear(); + + const extraction = engine.exports.extractMaskedArea('mask'); + setDocument({ + ...document, + layers: document.layers.map((layer) => + layer.id === 'upper' && layer.type === 'raster' + ? { + ...layer, + source: { + bitmap: { height: 40, imageName: 'upper-v2', width: 40 }, + offset: { x: 0, y: 0 }, + type: 'paint', + }, + } + : layer + ), + }); + + expect(await extraction).toEqual({ status: 'not-ready' }); + expect(store.dispatch).not.toHaveBeenCalled(); + engine.lifecycle.dispose(); + }); + + it('refuses extraction from a locked mask', async () => { + const document = maskedDoc(); + document.layers[0] = { ...document.layers[0]!, isLocked: true }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + + expect(await engine.exports.extractMaskedArea('mask')).toEqual({ status: 'unsupported' }); + engine.lifecycle.dispose(); + }); + + it('refuses extraction until the mask and contributor caches are ready', async () => { + const { engine, raf } = setup(); + raf.flush(); + + expect(await engine.exports.extractMaskedArea('mask')).toEqual({ status: 'not-ready' }); + expect(engine.document.getDocument()!.layers.map((layer) => layer.id)).toEqual(['mask', 'upper', 'below']); + engine.lifecycle.dispose(); + }); + + it('rejects missing, unsupported, and empty masks without changing the document', async () => { + const doc = maskedDoc(); + const { projectId, store } = createReducerBackedStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + + expect(await engine.exports.extractMaskedArea('missing')).toEqual({ status: 'missing' }); + expect(await engine.exports.extractMaskedArea('upper')).toEqual({ status: 'unsupported' }); + expect(await engine.exports.extractMaskedArea('mask')).toEqual({ status: 'not-ready' }); + expect(engine.document.getDocument()!.layers).toEqual(doc.layers); + engine.lifecycle.dispose(); + + const emptyDoc = maskedDoc(); + emptyDoc.layers[0] = maskLayer('mask'); + const emptyHarness = createReducerBackedStore(emptyDoc); + const emptyEngine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: emptyHarness.projectId, + store: emptyHarness.store, + }); + expect(await emptyEngine.exports.extractMaskedArea('mask')).toEqual({ status: 'empty' }); + emptyEngine.lifecycle.dispose(); + }); +}); + +// ---- mergeVisibleRasterLayers: guarded non-destructive composite ---- + +/** + * An EngineStore backed by the REAL workbench reducer, so the operation's + * prepared stack mutation advances the document and mirror atomically — the + * no-op mock store cannot exercise that transaction. + */ +const createReducerBackedStore = ( + document: CanvasDocumentContractV2, + mainBase?: string +): { dispatch: Mock<(action: EngineTestAction) => void>; projectId: string; store: EngineStore } => { + let state = createInitialWorkbenchState(); + const projectId = state.projects[0]!.id; + state = workbenchReducer(state, { + mutation: { document, type: 'replaceCanvasDocument' }, + projectId, + type: 'applyCanvasProjectMutation', + }); + if (mainBase) { + state = workbenchReducer(state, { + type: 'patchWidgetValues', + values: { model: { base: mainBase, key: `${mainBase}-main`, name: `${mainBase} main`, type: 'main' } }, + widgetId: 'generate', + }); + } + const listeners = new Set<() => void>(); + const dispatch = vi.fn((action: EngineTestAction) => { + state = workbenchReducer( + state, + isCanvasProjectMutation(action) ? { mutation: action, projectId, type: 'applyCanvasProjectMutation' } : action + ); + for (const listener of listeners) { + listener(); + } + }); + return { + dispatch, + projectId, + store: { + dispatch, + getState: () => state, + reducesCanvasMutations: true, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }, + }; +}; + +describe('staged result acceptance', () => { + const stagedCandidate = (): CanvasStagingCandidateContract => ({ + height: 20, + imageName: 'staged-result.png', + imageUrl: '/staged-result.png', + placement: { height: 40, opacity: 0.75, width: 30, x: 7, y: 9 }, + queuedAt: '2026-07-16T00:00:00.000Z', + sourceQueueItemId: 'queue-staged', + thumbnailUrl: '/staged-result-thumb.png', + width: 15, + }); + const stagedSelection = (candidate: CanvasStagingCandidateContract, selectedImageIndex = 0) => ({ + candidate, + selectedImageIndex, + }); + + it('commits through the project-bound engine and preserves layer identity and staging semantics across undo/redo', () => { + const reducer = createReducerBackedStore({ ...makeDoc(), selectedLayerId: 'a' }); + const candidate = stagedCandidate(); + reducer.store.dispatch({ + candidate, + projectId: reducer.projectId, + type: 'appendCanvasStagingCandidate', + }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: reducer.projectId, + store: reducer.store, + }); + + const result = engine.layers.commitStagedImage(stagedSelection(candidate)); + + expect(result.status).toBe('committed'); + if (result.status !== 'committed') { + throw new Error('expected commit'); + } + const projectAfterCommit = reducer.store.getState().projects[0]!; + const acceptedLayer = projectAfterCommit.canvas.document.layers[0]!; + const acceptedEvent = projectAfterCommit.events[0]!; + expect(acceptedLayer).toMatchObject({ + id: result.layerId, + opacity: 0.75, + source: { image: { imageName: 'staged-result.png' }, type: 'image' }, + transform: { scaleX: 2, scaleY: 2, x: 7, y: 9 }, + }); + expect(projectAfterCommit.canvas.stagingArea.pendingImages).toEqual([]); + expect(acceptedEvent.type).toBe('canvas-layer-accepted'); + + engine.history.undo(); + const projectAfterUndo = reducer.store.getState().projects[0]!; + expect(projectAfterUndo.canvas.document.layers).not.toContainEqual(expect.objectContaining({ id: result.layerId })); + expect(projectAfterUndo.canvas.document.selectedLayerId).toBe('a'); + expect(projectAfterUndo.canvas.stagingArea.pendingImages).toEqual([]); + + engine.history.redo(); + const projectAfterRedo = reducer.store.getState().projects[0]!; + expect(projectAfterRedo.canvas.document.layers[0]).toBe(acceptedLayer); + expect(projectAfterRedo.canvas.stagingArea.pendingImages).toEqual([]); + expect(projectAfterRedo.events).toContain(acceptedEvent); + expect(projectAfterRedo.events.filter((event) => event.id === acceptedEvent.id)).toHaveLength(1); + + engine.history.undo(); + expect(engine.stores.canRedo.get()).toBe(true); + reducer.store.dispatch({ + candidate: { ...candidate, imageName: 'new-staged-result.png' }, + projectId: reducer.projectId, + type: 'appendCanvasStagingCandidate', + }); + expect( + engine.layers.commitStagedImage(stagedSelection({ ...candidate, imageName: 'new-staged-result.png' })).status + ).toBe('committed'); + expect(engine.stores.canRedo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('returns stale without changing staging or history when the selected candidate key changes', () => { + const reducer = createReducerBackedStore(makeDoc()); + const first = stagedCandidate(); + const second = { ...stagedCandidate(), imageName: 'new-selection.png' }; + reducer.store.dispatch({ candidate: first, projectId: reducer.projectId, type: 'appendCanvasStagingCandidate' }); + reducer.store.dispatch({ candidate: second, projectId: reducer.projectId, type: 'appendCanvasStagingCandidate' }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: reducer.projectId, + store: reducer.store, + }); + const before = reducer.store.getState().projects[0]!.canvas; + + expect(engine.layers.commitStagedImage(stagedSelection(first, 1))).toEqual({ status: 'stale' }); + expect(reducer.store.getState().projects[0]!.canvas).toBe(before); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('returns busy without changing staging or history while an edit lease is active', async () => { + const reducer = createReducerBackedStore(makeDoc()); + reducer.store.dispatch({ + candidate: stagedCandidate(), + projectId: reducer.projectId, + type: 'appendCanvasStagingCandidate', + }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: reducer.projectId, + store: reducer.store, + }); + const exported = await engine.exports.exportLayerPixels('a'); + if (exported.status !== 'ok') { + throw new Error('expected current layer guard'); + } + const operations = getCanvasOperations(engine); + expect( + operations.controller.start({ + cleanupPreview: vi.fn(), + guard: exported.guard, + identity: { kind: 'filter', layerId: 'a', projectId: reducer.projectId }, + }) + ).not.toBeNull(); + const before = reducer.store.getState().projects[0]!.canvas; + + expect(engine.layers.commitStagedImage(stagedSelection(stagedCandidate()))).toEqual({ status: 'busy' }); + expect(reducer.store.getState().projects[0]!.canvas).toBe(before); + expect(engine.stores.canUndo.get()).toBe(false); + operations.controller.cancel(); + engine.lifecycle.dispose(); + }); + + it('returns busy without changing staging or history while a pointer gesture is active', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const reducer = createReducerBackedStore(makeDoc()); + reducer.store.dispatch({ + candidate: stagedCandidate(), + projectId: reducer.projectId, + type: 'appendCanvasStagingCandidate', + }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: reducer.projectId, + store: reducer.store, + }); + const screen = createInputCanvas(); + const overlay = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('view'); + overlay.fire('pointerdown', pointerAt(10, 10)); + const before = reducer.store.getState().projects[0]!.canvas; + + expect(engine.layers.commitStagedImage(stagedSelection(stagedCandidate()))).toEqual({ status: 'busy' }); + expect(reducer.store.getState().projects[0]!.canvas).toBe(before); + expect(engine.stores.canUndo.get()).toBe(false); + overlay.fire('pointerup', pointerAt(10, 10, { buttons: 0 })); + engine.lifecycle.dispose(); + }); + + it('returns stale without changing staging or history when the reducer rejects acceptance', () => { + const document = makeDoc(); + const canvas = makeCanvas(document); + canvas.stagingArea = { + ...canvas.stagingArea, + isVisible: true, + pendingImageIds: ['staged-result.png'], + pendingImages: [stagedCandidate()], + }; + const mutationPort: CanvasProjectMutationPort = { + dispatch: () => false, + getCanvasState: () => canvas, + subscribe: () => () => undefined, + }; + const { store } = createFakeStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + mutationPort, + projectId: 'p1', + store, + }); + + expect(engine.layers.commitStagedImage(stagedSelection(stagedCandidate()))).toEqual({ status: 'stale' }); + expect(canvas.stagingArea.pendingImages).toEqual([stagedCandidate()]); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('returns stale without history when reducer state cannot converge into the document mirror', () => { + const document = makeDoc(); + const canvas = makeCanvas(document); + canvas.stagingArea = { + ...canvas.stagingArea, + isVisible: true, + pendingImageIds: ['staged-result.png'], + pendingImages: [stagedCandidate()], + }; + let reducerCanvas = canvas; + let committed = false; + let postCommitReads = 0; + const mutationPort: CanvasProjectMutationPort = { + dispatch: (mutation) => { + if (mutation.type !== 'commitStagedImage') { + return false; + } + reducerCanvas = { + ...canvas, + document: { ...document, layers: [mutation.layer, ...document.layers], selectedLayerId: mutation.layer.id }, + stagingArea: { ...canvas.stagingArea, pendingImageIds: [], pendingImages: [] }, + }; + committed = true; + return true; + }, + getCanvasState: () => { + if (!committed) { + return canvas; + } + postCommitReads += 1; + return postCommitReads === 1 ? reducerCanvas : canvas; + }, + subscribe: () => () => undefined, + }; + const { store } = createFakeStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + mutationPort, + projectId: 'p1', + store, + }); + + expect(engine.layers.commitStagedImage(stagedSelection(stagedCandidate()))).toEqual({ status: 'stale' }); + expect(engine.document.getDocument()).toBe(document); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('returns missing without history for an absent staged candidate or project', () => { + const reducer = createReducerBackedStore(makeDoc()); + const candidateMissingEngine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: reducer.projectId, + store: reducer.store, + }); + expect( + candidateMissingEngine.layers.commitStagedImage( + stagedSelection({ ...stagedCandidate(), imageName: 'missing.png' }) + ) + ).toEqual({ + status: 'missing', + }); + expect(candidateMissingEngine.stores.canUndo.get()).toBe(false); + candidateMissingEngine.lifecycle.dispose(); + + const document = makeDoc(); + const { store } = createFakeStore(document); + const projectMissingEngine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + mutationPort: { + dispatch: () => false, + getCanvasState: () => null, + subscribe: () => () => undefined, + }, + projectId: 'missing-project', + store, + }); + expect(projectMissingEngine.layers.commitStagedImage(stagedSelection(stagedCandidate()))).toEqual({ + status: 'missing', + }); + expect(projectMissingEngine.stores.canUndo.get()).toBe(false); + projectMissingEngine.lifecycle.dispose(); + }); +}); + +describe('structural raster publication failure atomicity', () => { + const sentinelLayer = (): CanvasLayerContract => ({ + ...rasterLayer('selection-sentinel'), + isEnabled: false, + }); + + const createFaultHarness = (document: CanvasDocumentContractV2) => { + const faults = createStructuralFaultBackend(); + const bitmapStore = createSpyBitmapStore(); + const reducer = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: faults.backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: reducer.projectId, + store: reducer.store, + }); + return { ...reducer, bitmapStore, engine, faults }; + }; + + type StructuralFault = 'allocation' | 'draw'; + const structuralFaults = ['allocation', 'draw'] as const satisfies readonly StructuralFault[]; + const armStructuralFault = ( + faults: ReturnType, + failure: StructuralFault, + countdowns: Record + ): void => { + if (failure === 'allocation') { + faults.armAllocation(countdowns.allocation); + } else { + faults.armDraw(countdowns.draw); + } + }; + const structuralFaultMessage = (failure: StructuralFault): string => `structural cache ${failure} failed`; + + const expectInitialFailureExact = ( + harness: ReturnType, + expectedDocument: CanvasDocumentContractV2 + ): void => { + expect(harness.engine.document.getDocument()).toBe(expectedDocument); + expect(harness.engine.document.getDocument()).toEqual(expectedDocument); + expect(harness.engine.document.getDocument()!.selectedLayerId).toBe('selection-sentinel'); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(harness.bitmapStore.discardLayer).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + expect(harness.engine.stores.canRedo.get()).toBe(false); + }; + + it.each(structuralFaults)( + 'crop keeps document, cache, selection, and history exact when final cache %s fails', + async (failure) => { + const source = rasterLayer('crop-source'); + const document: CanvasDocumentContractV2 = { + ...makeDoc(), + bbox: { height: 7, width: 6, x: 2, y: 3 }, + layers: [source, sentinelLayer()], + selectedLayerId: 'selection-sentinel', + }; + const harness = createFaultHarness(document); + const sourceCache = await snapshotLayerCache(harness.engine, source.id); + const expectedDocument = harness.engine.document.getDocument()!; + (harness.store.dispatch as Mock).mockClear(); + harness.bitmapStore.markLayerDirty.mockClear(); + armStructuralFault(harness.faults, failure, { allocation: 3, draw: 3 }); + + await expect(harness.engine.layers.cropLayerToBbox(source.id)).resolves.toEqual({ + message: structuralFaultMessage(failure), + status: 'failed', + }); + + expectInitialFailureExact(harness, expectedDocument); + await expectLayerCacheExact(harness.engine, source.id, sourceCache); + harness.engine.lifecycle.dispose(); + } + ); + + it.each(structuralFaults)( + 'commitLayerCopy keeps document, cache, selection, and history exact when final cache %s fails', + async (failure) => { + const source = rasterLayer('copy-source'); + const document = { ...makeDoc(), layers: [source, sentinelLayer()], selectedLayerId: 'selection-sentinel' }; + const harness = createFaultHarness(document); + const sourceCache = await snapshotLayerCache(harness.engine, source.id); + const expectedDocument = harness.engine.document.getDocument()!; + const copy = { ...structuredClone(source), id: 'copy-result', name: 'Copy result' }; + (harness.store.dispatch as Mock).mockClear(); + harness.bitmapStore.markLayerDirty.mockClear(); + armStructuralFault(harness.faults, failure, { allocation: 1, draw: 1 }); + + expect(() => harness.engine.layers.commitLayerCopy('Copy layer', source.id, copy, 0)).toThrow( + structuralFaultMessage(failure) + ); + + expectInitialFailureExact(harness, expectedDocument); + expect(harness.engine.document.getDocument()!.layers.some((layer) => layer.id === copy.id)).toBe(false); + await expectLayerCacheExact(harness.engine, source.id, sourceCache); + harness.engine.lifecycle.dispose(); + } + ); + + it.each(structuralFaults)( + 'commitLayerConversion keeps document, cache, selection, and history exact when final cache %s fails', + async (failure) => { + const source = rasterLayer('conversion-source'); + const document = { ...makeDoc(), layers: [source, sentinelLayer()], selectedLayerId: 'selection-sentinel' }; + const harness = createFaultHarness(document); + const sourceCache = await snapshotLayerCache(harness.engine, source.id); + const expectedDocument = harness.engine.document.getDocument()!; + const live = harness.engine.document.getDocument()!.layers.find((layer) => layer.id === source.id)!; + if (live.type !== 'raster') { + throw new Error('expected a raster conversion source'); + } + const converted: CanvasLayerContract = { + ...structuredClone(live), + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + type: 'control', + withTransparencyEffect: false, + }; + (harness.store.dispatch as Mock).mockClear(); + harness.bitmapStore.markLayerDirty.mockClear(); + armStructuralFault(harness.faults, failure, { allocation: 1, draw: 1 }); + + expect(() => harness.engine.layers.commitLayerConversion('Convert layer', live, converted)).toThrow( + structuralFaultMessage(failure) + ); + + expectInitialFailureExact(harness, expectedDocument); + await expectLayerCacheExact(harness.engine, source.id, sourceCache); + harness.engine.lifecycle.dispose(); + } + ); + + it.each(structuralFaults)( + 'copyLayerToRaster keeps document, cache, selection, and history exact when final cache %s fails', + async (failure) => { + const source = rasterLayer('raster-copy-source'); + const document = { ...makeDoc(), layers: [source, sentinelLayer()], selectedLayerId: 'selection-sentinel' }; + const harness = createFaultHarness(document); + const sourceCache = await snapshotLayerCache(harness.engine, source.id); + const expectedDocument = harness.engine.document.getDocument()!; + (harness.store.dispatch as Mock).mockClear(); + harness.bitmapStore.markLayerDirty.mockClear(); + armStructuralFault(harness.faults, failure, { allocation: 1, draw: 1 }); + + await expect(harness.engine.layers.copyLayerToRaster(source.id)).rejects.toThrow(structuralFaultMessage(failure)); + + expectInitialFailureExact(harness, expectedDocument); + await expectLayerCacheExact(harness.engine, source.id, sourceCache); + harness.engine.lifecycle.dispose(); + } + ); + + it.each(structuralFaults)( + 'boolean merge keeps document, source caches, selection, and history exact when final cache %s fails', + async (failure) => { + const pair = twoPaintDoc(); + const document = { + ...pair, + layers: [...pair.layers, sentinelLayer()], + selectedLayerId: 'selection-sentinel', + }; + const harness = createFaultHarness(document); + const upperCache = await snapshotLayerCache(harness.engine, 'upper'); + const belowCache = await snapshotLayerCache(harness.engine, 'below'); + const expectedDocument = harness.engine.document.getDocument()!; + (harness.store.dispatch as Mock).mockClear(); + harness.bitmapStore.markLayerDirty.mockClear(); + armStructuralFault(harness.faults, failure, { allocation: 3, draw: 4 }); + + await expect(harness.engine.layers.booleanMergeRasterLayers('upper', 'intersect')).rejects.toThrow( + structuralFaultMessage(failure) + ); + + expectInitialFailureExact(harness, expectedDocument); + await expectLayerCacheExact(harness.engine, 'upper', upperCache); + await expectLayerCacheExact(harness.engine, 'below', belowCache); + harness.engine.lifecycle.dispose(); + } + ); + + it.each(structuralFaults)( + 'masked extraction keeps document, source caches, selection, and history exact when final cache %s fails', + async (failure) => { + const pair = twoPaintDoc(); + const mask: CanvasInpaintMaskLayerContract = { + ...maskLayer('atomic-mask'), + mask: { + bitmap: { height: 20, imageName: 'atomic-mask-bitmap', width: 20 }, + fill: { color: '#e07575', style: 'diagonal' }, + offset: { x: 15, y: 25 }, + }, + }; + const document = { + ...pair, + layers: [mask, ...pair.layers, sentinelLayer()], + selectedLayerId: 'selection-sentinel', + }; + const harness = createFaultHarness(document); + const maskCache = await snapshotLayerCache(harness.engine, mask.id); + const upperCache = await snapshotLayerCache(harness.engine, 'upper'); + const belowCache = await snapshotLayerCache(harness.engine, 'below'); + const expectedDocument = harness.engine.document.getDocument()!; + (harness.store.dispatch as Mock).mockClear(); + harness.bitmapStore.markLayerDirty.mockClear(); + armStructuralFault(harness.faults, failure, { allocation: 2, draw: 4 }); + + await expect(harness.engine.exports.extractMaskedArea(mask.id)).rejects.toThrow(structuralFaultMessage(failure)); + + expectInitialFailureExact(harness, expectedDocument); + await expectLayerCacheExact(harness.engine, mask.id, maskCache); + await expectLayerCacheExact(harness.engine, 'upper', upperCache); + await expectLayerCacheExact(harness.engine, 'below', belowCache); + harness.engine.lifecycle.dispose(); + } + ); + + const createCropReplayHarness = async () => { + const source = rasterLayer('crop-replay-source'); + const document: CanvasDocumentContractV2 = { + ...makeDoc(), + bbox: { height: 7, width: 6, x: 2, y: 3 }, + layers: [source, sentinelLayer()], + selectedLayerId: 'selection-sentinel', + }; + const harness = createFaultHarness(document); + await snapshotLayerCache(harness.engine, source.id); + await expect(harness.engine.layers.cropLayerToBbox(source.id)).resolves.toEqual({ status: 'cropped' }); + return { ...harness, originalDocument: document, source }; + }; + + it.each(structuralFaults)( + 'crop undo cache-%s failure leaves the committed state exact and retryable', + async (failure) => { + const harness = await createCropReplayHarness(); + const committedDocument = harness.engine.document.getDocument()!; + const committedCache = await snapshotLayerCache(harness.engine, harness.source.id); + armStructuralFault(harness.faults, failure, { allocation: 0, draw: 0 }); + + expect(() => harness.engine.history.undo()).toThrow(structuralFaultMessage(failure)); + + expect(harness.engine.document.getDocument()).toBe(committedDocument); + expect(harness.engine.document.getDocument()).toEqual(committedDocument); + expect(harness.engine.stores.canUndo.get()).toBe(true); + expect(harness.engine.stores.canRedo.get()).toBe(false); + await expectLayerCacheExact(harness.engine, harness.source.id, committedCache); + harness.engine.history.undo(); + expect(harness.engine.document.getDocument()).toEqual(harness.originalDocument); + harness.engine.lifecycle.dispose(); + } + ); + + it.each(structuralFaults)( + 'crop redo cache-%s failure leaves the restored state exact and retryable', + async (failure) => { + const harness = await createCropReplayHarness(); + const committedDocument = structuredClone(harness.engine.document.getDocument()!); + harness.engine.history.undo(); + const restoredDocument = harness.engine.document.getDocument()!; + const restoredCache = await snapshotLayerCache(harness.engine, harness.source.id); + armStructuralFault(harness.faults, failure, { allocation: 0, draw: 0 }); + + expect(() => harness.engine.history.redo()).toThrow(structuralFaultMessage(failure)); + + expect(harness.engine.document.getDocument()).toBe(restoredDocument); + expect(harness.engine.document.getDocument()).toEqual(harness.originalDocument); + expect(harness.engine.stores.canUndo.get()).toBe(false); + expect(harness.engine.stores.canRedo.get()).toBe(true); + await expectLayerCacheExact(harness.engine, harness.source.id, restoredCache); + harness.engine.history.redo(); + expect(harness.engine.document.getDocument()).toEqual(committedDocument); + harness.engine.lifecycle.dispose(); + } + ); + + it('commitLayerCopy redo cache-draw failure leaves the restored state exact and retryable', async () => { + const source = rasterLayer('copy-replay-source'); + const copy = { ...structuredClone(source), id: 'copy-replay-result', name: 'Copy replay result' }; + const document = { ...makeDoc(), layers: [source, sentinelLayer()], selectedLayerId: 'selection-sentinel' }; + const harness = createFaultHarness(document); + const originalCache = await snapshotLayerCache(harness.engine, source.id); + const originalDocument = harness.engine.document.getDocument()!; + expect(harness.engine.layers.commitLayerCopy('Copy layer', source.id, copy, 0)).toBe(true); + const committedDocument = structuredClone(harness.engine.document.getDocument()!); + harness.engine.history.undo(); + const restoredDocument = harness.engine.document.getDocument()!; + harness.faults.armDraw(0); + + expect(() => harness.engine.history.redo()).toThrow('structural cache draw failed'); + + expect(harness.engine.document.getDocument()).toBe(restoredDocument); + expect(harness.engine.document.getDocument()).toEqual(originalDocument); + expect(harness.engine.document.getDocument()!.layers.some((layer) => layer.id === copy.id)).toBe(false); + expect(harness.engine.stores.canUndo.get()).toBe(false); + expect(harness.engine.stores.canRedo.get()).toBe(true); + await expectLayerCacheExact(harness.engine, source.id, originalCache); + harness.engine.history.redo(); + expect(harness.engine.document.getDocument()).toEqual(committedDocument); + expect(harness.engine.document.getDocument()!.layers.filter((layer) => layer.id === copy.id)).toHaveLength(1); + harness.engine.lifecycle.dispose(); + }); + + it('commitLayerConversion keeps failed undo and redo cache preparation exact and retryable', async () => { + const source = rasterLayer('conversion-replay-source'); + const document = { ...makeDoc(), layers: [source, sentinelLayer()], selectedLayerId: 'selection-sentinel' }; + const harness = createFaultHarness(document); + await snapshotLayerCache(harness.engine, source.id); + const originalDocument = harness.engine.document.getDocument()!; + const live = originalDocument.layers.find((layer) => layer.id === source.id)!; + if (live.type !== 'raster') { + throw new Error('expected a raster conversion source'); + } + const converted: CanvasLayerContract = { + ...structuredClone(live), + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + type: 'control', + withTransparencyEffect: false, + }; + expect(harness.engine.layers.commitLayerConversion('Convert layer', live, converted)).toBe(true); + const committedDocument = harness.engine.document.getDocument()!; + const committedCache = await snapshotLayerCache(harness.engine, source.id); + harness.faults.armDraw(0); + + expect(() => harness.engine.history.undo()).toThrow('structural cache draw failed'); + + expect(harness.engine.document.getDocument()).toBe(committedDocument); + expect(harness.engine.stores.canUndo.get()).toBe(true); + expect(harness.engine.stores.canRedo.get()).toBe(false); + await expectLayerCacheExact(harness.engine, source.id, committedCache); + harness.engine.history.undo(); + expect(harness.engine.document.getDocument()).toEqual(originalDocument); + const restoredDocument = harness.engine.document.getDocument()!; + const restoredCache = await snapshotLayerCache(harness.engine, source.id); + harness.faults.armAllocation(0); + + expect(() => harness.engine.history.redo()).toThrow('structural cache allocation failed'); + + expect(harness.engine.document.getDocument()).toBe(restoredDocument); + expect(harness.engine.document.getDocument()).toEqual(originalDocument); + expect(harness.engine.stores.canUndo.get()).toBe(false); + expect(harness.engine.stores.canRedo.get()).toBe(true); + await expectLayerCacheExact(harness.engine, source.id, restoredCache); + harness.engine.history.redo(); + expect(harness.engine.document.getDocument()).toEqual(committedDocument); + harness.engine.lifecycle.dispose(); + }); + + it('boolean redo cache-draw failure leaves sources, selection, and history exact and retryable', async () => { + const pair = twoPaintDoc(); + const document = { + ...pair, + layers: [...pair.layers, sentinelLayer()], + selectedLayerId: 'selection-sentinel', + }; + const harness = createFaultHarness(document); + const upperCache = await snapshotLayerCache(harness.engine, 'upper'); + const belowCache = await snapshotLayerCache(harness.engine, 'below'); + const originalDocument = harness.engine.document.getDocument()!; + await expect(harness.engine.layers.booleanMergeRasterLayers('upper', 'exclude')).resolves.toBe('merged'); + const committedDocument = structuredClone(harness.engine.document.getDocument()!); + const resultId = harness.engine.document.getDocument()!.selectedLayerId!; + harness.engine.history.undo(); + const restoredDocument = harness.engine.document.getDocument()!; + harness.faults.armDraw(0); + + expect(() => harness.engine.history.redo()).toThrow('structural cache draw failed'); + + expect(harness.engine.document.getDocument()).toBe(restoredDocument); + expect(harness.engine.document.getDocument()).toEqual(originalDocument); + expect(harness.engine.document.getDocument()!.layers.some((layer) => layer.id === resultId)).toBe(false); + expect(harness.engine.stores.canUndo.get()).toBe(false); + expect(harness.engine.stores.canRedo.get()).toBe(true); + await expectLayerCacheExact(harness.engine, 'upper', upperCache); + await expectLayerCacheExact(harness.engine, 'below', belowCache); + harness.engine.history.redo(); + expect(harness.engine.document.getDocument()).toEqual(committedDocument); + harness.engine.lifecycle.dispose(); + }); + + it('masked extraction redo cache-draw failure leaves sources, selection, and history exact and retryable', async () => { + const pair = twoPaintDoc(); + const mask: CanvasInpaintMaskLayerContract = { + ...maskLayer('extract-replay-mask'), + mask: { + bitmap: { height: 20, imageName: 'extract-replay-mask-bitmap', width: 20 }, + fill: { color: '#e07575', style: 'diagonal' }, + offset: { x: 15, y: 25 }, + }, + }; + const document = { + ...pair, + layers: [mask, ...pair.layers, sentinelLayer()], + selectedLayerId: 'selection-sentinel', + }; + const harness = createFaultHarness(document); + const maskCache = await snapshotLayerCache(harness.engine, mask.id); + const upperCache = await snapshotLayerCache(harness.engine, 'upper'); + const belowCache = await snapshotLayerCache(harness.engine, 'below'); + const originalDocument = harness.engine.document.getDocument()!; + const extracted = await harness.engine.exports.extractMaskedArea(mask.id); + expect(extracted.status).toBe('extracted'); + if (extracted.status !== 'extracted') { + throw new Error('expected masked extraction'); + } + const committedDocument = structuredClone(harness.engine.document.getDocument()!); + harness.engine.history.undo(); + const restoredDocument = harness.engine.document.getDocument()!; + harness.faults.armDraw(0); + + expect(() => harness.engine.history.redo()).toThrow('structural cache draw failed'); + + expect(harness.engine.document.getDocument()).toBe(restoredDocument); + expect(harness.engine.document.getDocument()).toEqual(originalDocument); + expect(harness.engine.document.getDocument()!.layers.some((layer) => layer.id === extracted.layerId)).toBe(false); + expect(harness.engine.stores.canUndo.get()).toBe(false); + expect(harness.engine.stores.canRedo.get()).toBe(true); + await expectLayerCacheExact(harness.engine, mask.id, maskCache); + await expectLayerCacheExact(harness.engine, 'upper', upperCache); + await expectLayerCacheExact(harness.engine, 'below', belowCache); + harness.engine.history.redo(); + expect(harness.engine.document.getDocument()).toEqual(committedDocument); + harness.engine.lifecycle.dispose(); + }); + + it('copyLayerToRaster undo restores the exact prior selection', async () => { + const source = rasterLayer('selection-copy-source'); + const document = { ...makeDoc(), layers: [source, sentinelLayer()], selectedLayerId: 'selection-sentinel' }; + const harness = createFaultHarness(document); + const originalDocument = harness.engine.document.getDocument()!; + const copiedId = await harness.engine.layers.copyLayerToRaster(source.id); + expect(copiedId).not.toBeNull(); + + harness.engine.history.undo(); + + expect(harness.engine.document.getDocument()).toEqual(originalDocument); + expect(harness.engine.document.getDocument()!.selectedLayerId).toBe('selection-sentinel'); + expect(harness.engine.stores.canUndo.get()).toBe(false); + expect(harness.engine.stores.canRedo.get()).toBe(true); + harness.engine.lifecycle.dispose(); + }); + + it('keeps a reducer-rejected copy undo retryable until its prior selection exists again', async () => { + const source = rasterLayer('rejected-undo-source'); + const sentinel = sentinelLayer(); + const document = { ...makeDoc(), layers: [source, sentinel], selectedLayerId: sentinel.id }; + const harness = createFaultHarness(document); + const copiedId = await harness.engine.layers.copyLayerToRaster(source.id); + expect(copiedId).not.toBeNull(); + + harness.store.dispatch({ ids: [sentinel.id], type: 'removeCanvasLayers' }); + expect(harness.engine.document.getDocument()!.layers.some((layer) => layer.id === copiedId)).toBe(true); + + expect(() => harness.engine.history.undo()).toThrow('Canvas document mutation was rejected'); + expect(harness.engine.document.getDocument()!.layers.some((layer) => layer.id === copiedId)).toBe(true); + expect(harness.engine.stores.canUndo.get()).toBe(true); + expect(harness.engine.stores.canRedo.get()).toBe(false); + + harness.store.dispatch({ layer: sentinel, type: 'addCanvasLayer' }); + expect(() => harness.engine.history.undo()).not.toThrow(); + expect(harness.engine.document.getDocument()!.layers.some((layer) => layer.id === copiedId)).toBe(false); + expect(harness.engine.document.getDocument()!.selectedLayerId).toBe(sentinel.id); + expect(harness.engine.stores.canUndo.get()).toBe(false); + expect(harness.engine.stores.canRedo.get()).toBe(true); + harness.engine.lifecycle.dispose(); + }); + + it('publishes a prepared structural mutation to its bound project while another project is active', () => { + const source = rasterLayer('inactive-source'); + const document = { ...makeDoc(), layers: [source], selectedLayerId: source.id }; + const harness = createFaultHarness(document); + const liveSource = harness.engine.document.getDocument()!.layers[0]!; + const copy = { ...structuredClone(source), id: 'inactive-copy' }; + harness.store.dispatch({ type: 'createProject' }); + const otherProjectId = harness.store.getState().activeProjectId; + const otherBefore = structuredClone( + harness.store.getState().projects.find((project) => project.id === otherProjectId)!.canvas.document + ); + (harness.store.dispatch as Mock).mockClear(); + + expect(harness.engine.layers.commitLayerCopy('Copy layer', liveSource.id, copy, 0)).toBe(true); + + expect(harness.store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'applyCanvasLayerStackMutation' }) + ); + expect(harness.store.getState().projects.find((project) => project.id === otherProjectId)!.canvas.document).toEqual( + otherBefore + ); + expect(harness.engine.document.getDocument()?.layers[0]?.id).toBe(copy.id); + expect(harness.engine.stores.canUndo.get()).toBe(true); + harness.engine.lifecycle.dispose(); + }); + + it('crop generation-cancels a deferred pre-crop upload before publishing the new paint incarnation', async () => { + const source: CanvasRasterLayerContractV2 = { + ...(rasterLayer('crop-persistence-source') as CanvasRasterLayerContractV2), + source: { + bitmap: { height: 10, imageName: 'before-crop-paint', width: 10 }, + offset: { x: 0, y: 0 }, + type: 'paint', + }, + }; + const document: CanvasDocumentContractV2 = { + ...makeDoc(), + bbox: { height: 7, width: 6, x: 2, y: 3 }, + layers: [source, sentinelLayer()], + selectedLayerId: 'selection-sentinel', + }; + const reducer = createReducerBackedStore(document); + const staleUpload = createDeferred<{ height: number; imageName: string; width: number }>(); + let uploadCount = 0; + const uploadImage = vi.fn(() => { + uploadCount += 1; + return uploadCount === 1 + ? staleUpload.promise + : Promise.resolve({ height: 7, imageName: 'cropped-paint', width: 6 }); + }); + let placed: { offset: { x: number; y: number }; surface: RasterSurface } | null = null; + const bitmapStore = createBitmapStore({ + debounceMs: 1500, + dispatch: createTestMutationPort(reducer.store, reducer.projectId).dispatch, + encodeSurface: (surface) => + Promise.resolve(new Blob([`${surface.width}x${surface.height}`], { type: 'image/png' })), + getLayerSource: (layerId) => { + const layer = reducer.store + .getState() + .projects.find((project) => project.id === reducer.projectId) + ?.canvas.document.layers.find((candidate) => candidate.id === layerId); + return layer?.type === 'raster' || layer?.type === 'control' ? layer.source : null; + }, + getLayerSurface: () => placed, + hashBlob: (blob) => blob.text(), + maxUploadAttempts: 1, + retryDelaysMs: [], + sleep: () => Promise.resolve(), + uploadImage, + }); + const discardLayer = vi.spyOn(bitmapStore, 'discardLayer'); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: reducer.projectId, + store: reducer.store, + }); + const before = await snapshotLayerCache(engine, source.id); + placed = { offset: { x: before.rect.x, y: before.rect.y }, surface: before.surface }; + bitmapStore.markLayerDirty(source.id); + const barrier = bitmapStore.flushPendingUploads(); + for (let tick = 0; tick < 50 && uploadImage.mock.calls.length === 0; tick += 1) { + await Promise.resolve(); + } + expect(uploadImage).toHaveBeenCalledOnce(); + + await expect(engine.layers.cropLayerToBbox(source.id)).resolves.toEqual({ status: 'cropped' }); + const croppedCache = await snapshotLayerCache(engine, source.id); + placed = { + offset: { x: croppedCache.rect.x, y: croppedCache.rect.y }, + surface: croppedCache.surface, + }; + staleUpload.resolve({ height: 10, imageName: 'stale-pre-crop-paint', width: 10 }); + await barrier; + + const bitmapUpdates = (reducer.store.dispatch as Mock).mock.calls + .map((call) => call[0] as EngineTestAction) + .filter((action) => action.type === 'updateCanvasLayerSource' && action.id === source.id); + expect(discardLayer).toHaveBeenCalledWith(source.id); + expect(bitmapUpdates).toHaveLength(1); + expect(bitmapUpdates).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ source: { bitmap: expect.objectContaining({ imageName: 'stale-pre-crop-paint' }) } }), + ]) + ); + expect(bitmapUpdates[0]).toMatchObject({ + source: { + bitmap: { height: 7, imageName: 'cropped-paint', width: 6 }, + offset: { x: 2, y: 3 }, + type: 'paint', + }, + }); + expect(engine.document.getDocument()!.selectedLayerId).toBe('selection-sentinel'); + expect(engine.document.getDocument()!.layers.find((layer) => layer.id === source.id)).toMatchObject({ + source: { + bitmap: { height: 7, imageName: 'cropped-paint', width: 6 }, + offset: { x: 2, y: 3 }, + type: 'paint', + }, + }); + expect(engine.stores.canUndo.get()).toBe(true); + await expectLayerCacheExact(engine, source.id, croppedCache); + engine.lifecycle.dispose(); + }); +}); + +describe('mergeVisibleRasterLayers', () => { + /** [upper raster, mask, lower raster] — the interleaved case round 1 no-oped. */ + const interleavedDoc = (): CanvasDocumentContractV2 => { + const base = twoPaintDoc(); + const [upper, below] = base.layers as [CanvasLayerContract, CanvasLayerContract]; + return { ...base, layers: [upper, maskLayer('mid-mask'), below] }; + }; + + const setup = (doc: CanvasDocumentContractV2) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const { projectId, store } = createReducerBackedStore(doc); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend: StubRasterBackend = { + ...base, + createSurface: (width, height) => { + const surface = base.createSurface(width, height); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + return { engine, raf, store, surfaces }; + }; + + it('waits for contributor rasterization before applying one atomic insertion', async () => { + const { engine, raf } = setup(interleavedDoc()); + const pending = engine.layers.mergeVisibleRasterLayers(); + expect(engine.document.getDocument()!.layers.map((layer) => layer.id)).toEqual(['upper', 'mid-mask', 'below']); + + raf.flush(); + await flushMicrotasks(); + raf.flush(); + expect(await pending).toBe('merged'); + expect( + engine.document + .getDocument()! + .layers.slice(1) + .map((layer) => layer.id) + ).toEqual(['upper', 'mid-mask', 'below']); + + engine.lifecycle.dispose(); + }); + + it('creates a selected raster at index zero and preserves every source layer', async () => { + const { engine, raf } = setup(interleavedDoc()); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + expect(await engine.layers.mergeVisibleRasterLayers()).toBe('merged'); + + const layers = engine.document.getDocument()!.layers; + expect(layers).toHaveLength(4); + expect(layers[0]).toMatchObject({ + blendMode: 'normal', + isEnabled: true, + isLocked: false, + name: 'upper merged', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }); + expect(layers.slice(1).map((layer) => layer.id)).toEqual(['upper', 'mid-mask', 'below']); + expect(engine.document.getDocument()!.selectedLayerId).toBe(layers[0]!.id); + + engine.lifecycle.dispose(); + }); + + it('includes locked parametric rasters, excludes hidden rasters, and composites bottom-to-top', async () => { + const doc = twoPaintDoc(); + const upper: CanvasRasterLayerContractV2 = { + ...(doc.layers[0] as CanvasRasterLayerContractV2), + isLocked: true, + }; + const below: CanvasRasterLayerContractV2 = { + ...(doc.layers[1] as CanvasRasterLayerContractV2), + source: { + angle: 0, + height: 60, + kind: 'linear', + stops: [ + { color: '#000000', offset: 0 }, + { color: '#ffffff', offset: 1 }, + ], + type: 'gradient', + width: 60, + }, + }; + const hidden: CanvasRasterLayerContractV2 = { + ...(doc.layers[1] as CanvasRasterLayerContractV2), + id: 'hidden', + isEnabled: false, + name: 'hidden', + source: { bitmap: { height: 500, imageName: 'hidden-bmp', width: 500 }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: -1000, y: -1000 }, + }; + const sourceLayers = [upper, maskLayer('mid-mask'), hidden, below]; + const { engine, raf, surfaces } = setup({ ...doc, layers: sourceLayers }); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + expect(await engine.layers.mergeVisibleRasterLayers()).toBe('merged'); + const merged = engine.document.getDocument()!.layers[0]!; + expect(merged).toMatchObject({ source: { offset: { x: 10, y: 20 }, type: 'paint' }, type: 'raster' }); + expect(engine.document.getDocument()!.layers.slice(1)).toEqual(sourceLayers); + + const composite = surfaces.find( + (surface) => + surface.width === 60 && + surface.height === 60 && + surface.callLog.some( + (entry) => entry.op === 'set' && entry.args[0] === 'globalCompositeOperation' && entry.args[1] === 'multiply' + ) + ); + expect(composite).toBeDefined(); + expect( + composite?.callLog + .filter((entry) => entry.op === 'drawImage') + .map((entry) => (entry.args[0] as { width: number }).width) + ).toEqual([60, 40]); + expect( + composite?.callLog + .filter((entry) => entry.op === 'set' && entry.args[0] === 'globalAlpha') + .map((entry) => entry.args[1]) + ).toEqual([1, 0.5, 1]); + + engine.lifecycle.dispose(); + }); + + it('creates another composite layer when invoked repeatedly', async () => { + const { engine, raf } = setup(interleavedDoc()); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + expect(await engine.layers.mergeVisibleRasterLayers()).toBe('merged'); + const firstId = engine.document.getDocument()!.layers[0]!.id; + expect(await engine.layers.mergeVisibleRasterLayers()).toBe('merged'); + const layers = engine.document.getDocument()!.layers; + expect(layers).toHaveLength(5); + expect(layers[0]!.id).not.toBe(firstId); + expect(layers[1]!.id).toBe(firstId); + expect(layers.slice(2).map((layer) => layer.id)).toEqual(['upper', 'mid-mask', 'below']); + + engine.lifecycle.dispose(); + }); + + it('undoes and redoes only the generated layer', async () => { + const { engine, raf } = setup(interleavedDoc()); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + const before = engine.document.getDocument()!; + + expect(await engine.layers.mergeVisibleRasterLayers()).toBe('merged'); + const merged = engine.document.getDocument()!.layers[0]!; + + engine.history.undo(); + expect(engine.document.getDocument()!.layers).toEqual(before.layers); + expect(engine.document.getDocument()!.selectedLayerId).toBe(before.selectedLayerId); + + engine.history.redo(); + expect(engine.document.getDocument()!.layers[0]).toEqual(merged); + expect(engine.document.getDocument()!.layers.slice(1)).toEqual(before.layers); + expect(engine.document.getDocument()!.selectedLayerId).toBe(merged.id); + + engine.lifecycle.dispose(); + }); + + it('blocks the group merge without closing an operation and restores it after cancel', async () => { + const { engine, raf } = setup(interleavedDoc()); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + const exported = await engine.exports.exportLayerPixels('upper'); + if (exported.status !== 'ok') { + throw new Error('expected an operation guard'); + } + const session = getCanvasOperations(engine).controller.start({ + cleanupPreview: vi.fn(), + guard: exported.guard, + identity: { kind: 'filter', layerId: 'upper', projectId: engine.projectId }, + }); + + expect(await engine.layers.mergeVisibleRasterLayers()).toBe('busy'); + expect(engine.document.getDocument()!.layers.map((layer) => layer.id)).toEqual(['upper', 'mid-mask', 'below']); + expect(getCanvasOperations(engine).controller.getSnapshot()).toMatchObject({ + identity: { kind: 'filter' }, + status: 'active', + }); + + session?.cancel(); + expect(await engine.layers.mergeVisibleRasterLayers()).toBe('merged'); + engine.lifecycle.dispose(); + }); + + it('returns nothing for empty raster layers and preserves them', async () => { + const base = twoPaintDoc(); + const doc: CanvasDocumentContractV2 = { + ...base, + layers: base.layers.map((layer) => ({ + ...layer, + source: { bitmap: null, offset: { x: 0, y: 0 }, type: 'paint' as const }, + })), + }; + const { engine, raf } = setup(doc); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + expect(engine.document.getDocument()!.layers.map((layer) => layer.id)).toEqual(['upper', 'below']); + expect(await engine.layers.mergeVisibleRasterLayers()).toBe('nothing'); + expect(engine.document.getDocument()!.layers.map((layer) => layer.id)).toEqual(['upper', 'below']); + + engine.lifecycle.dispose(); + }); +}); + +// ---- rasterizeLayer: parametric → paint, undoable via param re-convert ---- + +const shapeLayerDoc = (over: { isLocked?: boolean } = {}): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'normal', + id: 'shape1', + isEnabled: true, + isLocked: over.isLocked ?? false, + name: 'Shape', + opacity: 1, + source: { fill: '#ff0000', height: 40, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 60 }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 }, + type: 'raster', + }, + ], + selectedLayerId: 'shape1', + version: 2, + width: 100, +}); + +describe('rasterizeLayer (parametric → paint)', () => { + const setup = (doc: CanvasDocumentContractV2) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const { setDocument, store } = createReactiveStore(doc); + const dispatch = store.dispatch as Mock; + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + return { backend, dispatch, engine, raf, setDocument, surfaces }; + }; + + const convertCalls = (dispatch: Mock): EngineTestAction[] => + dispatch.mock.calls.map((call) => call[0] as EngineTestAction).filter((a) => a.type === 'convertCanvasLayer'); + + it('bakes to a CONTENT-sized paint layer at identity and dispatches convertCanvasLayer with the offset', () => { + const { dispatch, engine, surfaces } = setup(shapeLayerDoc()); + const before = surfaces.length; + + expect(engine.layers.rasterizeLayer('shape1')).toBe(true); + + const converts = convertCalls(dispatch); + expect(converts).toHaveLength(1); + const convert = converts[0]; + expect(convert).toMatchObject({ id: 'shape1', targetType: 'raster', type: 'convertCanvasLayer' }); + if (convert?.type === 'convertCanvasLayer') { + expect(convert.layer.type).toBe('raster'); + if (convert.layer.type === 'raster') { + // Content-sized: the shape (60×40) at transform (10,20) bakes to a paint + // layer whose bitmap sits at offset (10,20); the transform resets to identity. + expect(convert.layer.source).toEqual({ bitmap: null, offset: { x: 10, y: 20 }, type: 'paint' }); + expect(convert.layer.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }); + } + } + + // A fresh CONTENT-sized surface (the transformed shape bounds, 60×40) was + // allocated and the parametric cache baked into it. + const baked = surfaces[before]; + expect(baked).toBeDefined(); + expect(baked?.width).toBe(60); + expect(baked?.height).toBe(40); + expect(baked?.callLog.some((e) => e.op === 'drawImage')).toBe(true); + + engine.lifecycle.dispose(); + }); + + it('undo re-converts to the ORIGINAL parametric source (no pixel snapshot)', () => { + const { dispatch, engine } = setup(shapeLayerDoc()); + engine.layers.rasterizeLayer('shape1'); + + engine.history.undo(); + const converts = convertCalls(dispatch); + // forward convert + undo convert. + expect(converts).toHaveLength(2); + const undoConvert = converts[1]; + if (undoConvert?.type === 'convertCanvasLayer' && undoConvert.layer.type === 'raster') { + expect(undoConvert.layer.source).toEqual({ + fill: '#ff0000', + height: 40, + kind: 'rect', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 60, + }); + expect(undoConvert.layer.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 }); + } else { + throw new Error('expected undo to re-convert to the shape source'); + } + + // Redo re-applies the paint conversion (bitmap:null at the baked offset). + engine.history.redo(); + const afterRedo = convertCalls(dispatch); + expect(afterRedo).toHaveLength(3); + if (afterRedo[2]?.type === 'convertCanvasLayer' && afterRedo[2].layer.type === 'raster') { + expect(afterRedo[2].layer.source).toEqual({ bitmap: null, offset: { x: 10, y: 20 }, type: 'paint' }); + } + engine.lifecycle.dispose(); + }); + + it('redo re-bakes from params rather than pinning the doc-sized surface (byte-budget honesty)', () => { + // Regression: the history entry declared bytes:256 but captured the doc-sized + // `baked` surface in its redo closure, so repeated rasterizes could retain + // gigabytes invisible to HISTORY_BYTE_BUDGET. Redo must re-bake instead. + const { engine, surfaces } = setup(shapeLayerDoc()); + + expect(engine.layers.rasterizeLayer('shape1')).toBe(true); + const afterApply = surfaces.length; + + engine.history.undo(); + // Undo only re-converts (a dispatch) — it bakes nothing. + expect(surfaces.length).toBe(afterApply); + + engine.history.redo(); + // Redo allocates FRESH surfaces: it re-baked from params (content-sized to the + // transformed shape bounds, 60×40) rather than reusing a surface pinned by the + // entry. + expect(surfaces.length).toBeGreaterThan(afterApply); + const rebaked = surfaces.at(-1); + expect(rebaked?.width).toBe(60); + expect(rebaked?.height).toBe(40); + expect(rebaked?.callLog.some((e) => e.op === 'drawImage')).toBe(true); + + engine.lifecycle.dispose(); + }); + + it('rasterizes a gradient layer', () => { + const doc = shapeLayerDoc(); + doc.layers[0] = { + ...doc.layers[0], + source: { angle: 45, kind: 'linear', stops: [{ color: '#000', offset: 0 }], type: 'gradient' }, + } as CanvasLayerContract; + const { dispatch, engine } = setup(doc); + expect(engine.layers.rasterizeLayer('shape1')).toBe(true); + expect(convertCalls(dispatch)).toHaveLength(1); + engine.lifecycle.dispose(); + }); + + it('is a no-op for a locked layer, a missing layer, and a non-parametric (paint) layer', () => { + const { dispatch, engine } = setup(shapeLayerDoc({ isLocked: true })); + expect(engine.layers.rasterizeLayer('shape1')).toBe(false); + expect(engine.layers.rasterizeLayer('nope')).toBe(false); + expect(convertCalls(dispatch)).toHaveLength(0); + engine.lifecycle.dispose(); + + const paintDoc = shapeLayerDoc(); + paintDoc.layers[0] = { ...paintDoc.layers[0], source: { bitmap: null, type: 'paint' } } as CanvasLayerContract; + const paint = setup(paintDoc); + expect(paint.engine.layers.rasterizeLayer('shape1')).toBe(false); + paint.engine.lifecycle.dispose(); + }); + + // ---- rasterize → undo → bitmap-store flush: source-type guard -------- + // + // Reviewer-flagged bug: rasterize bakes the shape to a paint layer and marks + // it dirty in the bitmap store; undo re-converts it back to the parametric + // shape. Nothing previously cleared the pending dirty mark, so the eventual + // debounced (or barrier) flush would encode the paint-cache surface — still + // populated, since a source swap doesn't clear it — and dispatch + // `updateCanvasLayerSource({ type: 'paint', ... })`, silently flipping the + // parametric layer back to paint with stale, wrong-extent pixels. + // + // These tests wire a REAL `createBitmapStore` (exercising the actual guard + // in `flushLayer`) through `opts.bitmapStore`, with test-controlled + // encode/upload stubs (no real network) and a `getLayerSurface` that always + // resolves — mirroring the bug precondition that a source swap does NOT + // clear the cache. `getLayerSource` reads the engine's own mirrored + // document, so it reflects the exact reducer round trip the test drives via + // `setDocument`. + describe('rasterize → undo → bitmap-store flush (source-type guard)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + const setupWithRealBitmapStore = (doc: CanvasDocumentContractV2) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const { setDocument, store } = createReactiveStore(doc); + const dispatch = store.dispatch as Mock; + + const encodeSurface = vi.fn(() => Promise.resolve(new Blob(['pixels'], { type: 'image/png' }))); + const uploadImage = vi.fn(() => Promise.resolve({ height: 100, imageName: 'img-x', width: 100 })); + // Always resolves, regardless of the layer's current source: mirrors the + // real cache, whose surface a source swap does NOT clear (only marks stale). + const fakeSurface = createTestStubRasterBackend().createSurface(10, 10); + + // Forward-declared: `getLayerSource` closes over it, but is only ever + // CALLED once `engine` is assigned below (bitmap-store flushes never + // happen synchronously during construction). + let engine: ReturnType; + const bitmapStore = createBitmapStore({ + dispatch: createTestMutationPort(store, 'p1').dispatch, + encodeSurface, + getLayerSource: (layerId) => { + const layer = engine.document.getDocument()?.layers.find((candidate) => candidate.id === layerId); + return layer && (layer.type === 'raster' || layer.type === 'control') ? layer.source : null; + }, + getLayerSurface: () => ({ offset: { x: 0, y: 0 }, surface: fakeSurface }), + hashBlob: (blob) => blob.text(), + retryDelaysMs: [1], + sleep: () => Promise.resolve(), + uploadImage, + }); + + engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + + return { dispatch, encodeSurface, engine, raf, setDocument, uploadImage }; + }; + + /** + * Applies the most recently dispatched `convertCanvasLayer` action onto + * `doc`, simulating what the real reducer would do — `createReactiveStore`'s + * `dispatch` is a bare spy and does not mutate state on its own. + */ + const applyLastConvert = (doc: CanvasDocumentContractV2, dispatch: Mock): CanvasDocumentContractV2 => { + const converts = convertCalls(dispatch); + const last = converts.at(-1); + if (last?.type !== 'convertCanvasLayer') { + throw new Error('expected a convertCanvasLayer dispatch'); + } + return { ...doc, layers: doc.layers.map((layer) => (layer.id === last.id ? last.layer : layer)) }; + }; + + /** + * Rasterizes `shape1` (paint bake, dirty-marks the bitmap store), applies + * that conversion to the mirrored document, then undoes it and applies + * THAT conversion too — the full reducer round trip a live document would + * go through. Leaves the document back at its original parametric shape + * source, with the paint-bake dirty mark for `shape1` still pending. + */ + const rasterizeThenUndo = () => { + vi.useFakeTimers(); + let doc = shapeLayerDoc(); + const harness = setupWithRealBitmapStore(doc); + const { dispatch, engine, raf, setDocument } = harness; + + expect(engine.layers.rasterizeLayer('shape1')).toBe(true); + doc = applyLastConvert(doc, dispatch); + setDocument(doc); + raf.flush(); + + engine.history.undo(); + doc = applyLastConvert(doc, dispatch); + setDocument(doc); + raf.flush(); + + return harness; + }; + + /** Every dispatched `updateCanvasLayerSource` whose source is `paint`. */ + const paintSourceDispatches = (dispatch: Mock): Extract[] => + dispatch.mock.calls + .map((call) => call[0] as EngineTestAction) + .filter( + (action): action is Extract => + action.type === 'updateCanvasLayerSource' && action.source.type === 'paint' + ); + + it('await flushPendingUploads(): the layer stays the parametric shape and no paint dispatch fires', async () => { + const { dispatch, engine, uploadImage } = rasterizeThenUndo(); + + await engine.lifecycle.flushPendingUploads(); + + // The guard drops the flush before it ever encodes/uploads. + expect(uploadImage).not.toHaveBeenCalled(); + expect(paintSourceDispatches(dispatch)).toHaveLength(0); + const layer = engine.document.getDocument()!.layers[0] as CanvasRasterLayerContractV2; + expect(layer.source.type).toBe('shape'); + + engine.lifecycle.dispose(); + }); + + it('advancing the debounce timer: the layer stays the parametric shape and no paint dispatch fires', async () => { + const { dispatch, engine, uploadImage } = rasterizeThenUndo(); + + await vi.advanceTimersByTimeAsync(1500); + + expect(uploadImage).not.toHaveBeenCalled(); + expect(paintSourceDispatches(dispatch)).toHaveLength(0); + const layer = engine.document.getDocument()!.layers[0] as CanvasRasterLayerContractV2; + expect(layer.source.type).toBe('shape'); + + engine.lifecycle.dispose(); + }); + + it('redo after rasterize → flush → undo re-dispatches the paint image ref (fix round 2: no permanent bitmap:null)', async () => { + // Reviewer round 2, data-loss finding: rasterize → flush (contract lands + // on img-x, and the store's `lastApplied` remembers img-x) → undo (back + // to shape) → redo (convertCanvasLayer resets to `paint {bitmap: null}`) + // → a further flush re-bakes IDENTICAL pixels, so the content-hash dedupe + // resolves back to img-x — but the old `lastApplied`-based redundant- + // dispatch skip treated that as "already applied" and swallowed the + // dispatch, permanently stranding the document on `bitmap: null`. This + // drives the FULL sequence (including the first flush landing for real) + // and asserts the second flush actually re-dispatches the ref. + vi.useFakeTimers(); + let doc = shapeLayerDoc(); + const { dispatch, engine, raf, setDocument, uploadImage } = setupWithRealBitmapStore(doc); + + // Rasterize → bake to paint → flush lands img-x. + expect(engine.layers.rasterizeLayer('shape1')).toBe(true); + doc = applyLastConvert(doc, dispatch); + setDocument(doc); + raf.flush(); + + await vi.advanceTimersByTimeAsync(1500); + await engine.lifecycle.flushPendingUploads(); + expect(uploadImage).toHaveBeenCalledTimes(1); + + const firstPersist = paintSourceDispatches(dispatch).at(-1); + expect(firstPersist).toBeDefined(); + expect(firstPersist?.source).toMatchObject({ bitmap: { imageName: 'img-x' }, type: 'paint' }); + // Apply the persisted ref onto the mirrored document, as the real reducer + // would — the document now genuinely points at img-x, not `bitmap: null`. + doc = { + ...doc, + layers: doc.layers.map((layer) => + layer.id === firstPersist?.id ? { ...layer, source: firstPersist.source } : layer + ), + }; + setDocument(doc); + raf.flush(); + + // Undo → back to the parametric shape source. + engine.history.undo(); + doc = applyLastConvert(doc, dispatch); + setDocument(doc); + raf.flush(); + + // Redo → paint bake again; the fresh conversion lands on `bitmap: null` + // (only the debounced flush fills in the persisted ref). + engine.history.redo(); + doc = applyLastConvert(doc, dispatch); + setDocument(doc); + raf.flush(); + const layerAfterRedo = engine.document.getDocument()!.layers[0] as CanvasRasterLayerContractV2; + expect(layerAfterRedo.source).toEqual({ bitmap: null, offset: { x: 10, y: 20 }, type: 'paint' }); + + // Flush again: the re-baked pixels are identical, so the content hash + // dedupes back to img-x with NO new upload — but the ref must still be + // re-dispatched into the contract, since the document currently reads + // `bitmap: null`, not img-x. + await vi.advanceTimersByTimeAsync(1500); + await engine.lifecycle.flushPendingUploads(); + + expect(uploadImage).toHaveBeenCalledTimes(1); // dedupe hit: no re-upload + const dispatchesAfterRedo = paintSourceDispatches(dispatch); + const secondPersist = dispatchesAfterRedo.at(-1); + expect(secondPersist).toBeDefined(); + expect(secondPersist).not.toBe(firstPersist); + expect(secondPersist?.source).toMatchObject({ bitmap: { imageName: 'img-x' }, type: 'paint' }); + + // Applying it restores the document's bitmap ref — no longer null. + doc = { + ...doc, + layers: doc.layers.map((layer) => + layer.id === secondPersist?.id ? { ...layer, source: secondPersist.source } : layer + ), + }; + setDocument(doc); + const layerFinal = engine.document.getDocument()!.layers[0] as CanvasRasterLayerContractV2; + expect(layerFinal.source.type).toBe('paint'); + if (layerFinal.source.type === 'paint') { + expect(layerFinal.source.bitmap).not.toBeNull(); + expect(layerFinal.source.bitmap?.imageName).toBe('img-x'); + } + + engine.lifecycle.dispose(); + }); + + it('sanity check: a normal (non-reverted) rasterize DOES flush to paint (the guard only blocks the reverted case)', async () => { + vi.useFakeTimers(); + let doc = shapeLayerDoc(); + const { dispatch, engine, raf, setDocument, uploadImage } = setupWithRealBitmapStore(doc); + + expect(engine.layers.rasterizeLayer('shape1')).toBe(true); + doc = applyLastConvert(doc, dispatch); + setDocument(doc); + raf.flush(); + + await vi.advanceTimersByTimeAsync(1500); + await engine.lifecycle.flushPendingUploads(); + + expect(uploadImage).toHaveBeenCalledTimes(1); + expect(paintSourceDispatches(dispatch)).toHaveLength(1); + + engine.lifecycle.dispose(); + }); + }); +}); + +// ---- nudgeSelectedLayer: bounds/lock logic + coalescing ---------------- + +const selectedImageDoc = ( + overrides: { isLocked?: boolean; isEnabled?: boolean } = {}, + selectedLayerId: string | null = 'a' +): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [{ ...rasterLayer('a'), ...overrides }], + selectedLayerId, + version: 2, + width: 100, +}); + +describe('nudgeSelectedLayer', () => { + const setup = (doc: CanvasDocumentContractV2) => { + const { store } = createFakeStore(doc); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + return { dispatch, engine }; + }; + + it('no-ops with no selection', () => { + const { dispatch, engine } = setup(selectedImageDoc({}, null)); + engine.layers.nudgeSelectedLayer(1, 0); + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('no-ops on a locked selected layer', () => { + const { dispatch, engine } = setup(selectedImageDoc({ isLocked: true })); + engine.layers.nudgeSelectedLayer(0, 1); + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('no-ops on a hidden selected layer', () => { + const { dispatch, engine } = setup(selectedImageDoc({ isEnabled: false })); + engine.layers.nudgeSelectedLayer(0, 1); + expect(dispatch).not.toHaveBeenCalled(); + engine.lifecycle.dispose(); + }); + + it('dispatches a transform update and records an undoable entry', () => { + const { dispatch, engine } = setup(selectedImageDoc()); + engine.layers.nudgeSelectedLayer(3, -2); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenNthCalledWith(1, { + id: 'a', + patch: { transform: { x: 3, y: -2 } }, + type: 'updateCanvasLayer', + }); + expect(engine.stores.canUndo.get()).toBe(true); + + // Undo dispatches the inverse (back to the original position). + engine.history.undo(); + expect(dispatch).toHaveBeenNthCalledWith(2, { + id: 'a', + patch: { transform: { x: 0, y: 0 } }, + type: 'updateCanvasLayer', + }); + engine.lifecycle.dispose(); + }); + + it('coalesces a rapid same-layer burst into one history entry', () => { + vi.spyOn(Date, 'now').mockReturnValue(1_000); + const { engine } = setup(selectedImageDoc()); + engine.layers.nudgeSelectedLayer(1, 0); + engine.layers.nudgeSelectedLayer(1, 0); + engine.layers.nudgeSelectedLayer(1, 0); + // A single undo empties the stack: the burst collapsed to one entry. + expect(engine.stores.canUndo.get()).toBe(true); + engine.history.undo(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + vi.restoreAllMocks(); + }); + + it('starts a fresh entry once the coalescing window elapses', () => { + const now = vi.spyOn(Date, 'now'); + now.mockReturnValue(1_000); + const { engine } = setup(selectedImageDoc()); + engine.layers.nudgeSelectedLayer(1, 0); + now.mockReturnValue(2_000); // > 500ms later + engine.layers.nudgeSelectedLayer(1, 0); + // Two distinct entries: one undo still leaves something to undo. + engine.history.undo(); + expect(engine.stores.canUndo.get()).toBe(true); + engine.history.undo(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + vi.restoreAllMocks(); + }); +}); + +// ---- move tool: drag gesture through the pointer pipeline --------------- + +describe('move tool: drag through the pipeline', () => { + it('commits one structural transform update and records an undoable entry', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + // A document-sized paint layer is hit-testable everywhere and pre-selected. + const { store } = createReactiveStore(paintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('move'); + + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(50, 30)); + overlay.fire('pointerup', pointerAt(50, 30, { buttons: 0 })); + + const transformUpdates = dispatch.mock.calls + .map((call) => call[0] as EngineTestAction) + .filter((action) => action.type === 'updateCanvasLayer'); + expect(transformUpdates).toHaveLength(1); + expect(transformUpdates[0]).toMatchObject({ id: 'paint1', type: 'updateCanvasLayer' }); + expect(engine.stores.canUndo.get()).toBe(true); + + engine.lifecycle.dispose(); + }); + + it('a click (no drag) dispatches a selection change and no transform update', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(paintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('move'); + + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointerup', pointerAt(20, 20, { buttons: 0 })); + + const actions = dispatch.mock.calls.map((call) => call[0] as EngineTestAction); + expect(actions.some((action) => action.type === 'updateCanvasLayer')).toBe(false); + expect(actions.some((action) => action.type === 'setCanvasSelectedLayer')).toBe(true); + expect(engine.stores.canUndo.get()).toBe(false); + + engine.lifecycle.dispose(); + }); +}); + +// ---- ride-along: composed auto-create + stroke history entry ------------ + +const imageSelectedDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + // Selected layer is an image (not paintable) → a brush stroke auto-creates a + // fresh paint layer for the gesture. + layers: [rasterLayer('img')], + selectedLayerId: 'img', + version: 2, + width: 100, +}); + +describe('engine-owned history: composed auto-create + stroke entry', () => { + it('undo removes the auto-created layer (no pixel restore); redo re-adds it and re-applies the stroke', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(imageSelectedDoc()); + const dispatch = store.dispatch as Mock; + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const strokes: StrokeCommittedEvent[] = []; + engine.tools.onStrokeCommitted((event) => strokes.push(event)); + + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('brush'); + + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + + // The gesture auto-created a paint layer and reported it on the commit event. + expect(strokes).toHaveLength(1); + const created = strokes[0]!.createdLayer; + expect(created).toBeDefined(); + const newLayerId = created!.layer.id; + expect(engine.stores.canUndo.get()).toBe(true); + + // The auto-create dispatched addCanvasLayer for the new id. + const addCalls = dispatch.mock.calls + .map((call) => call[0] as EngineTestAction) + .filter((action) => action.type === 'addCanvasLayer'); + expect(addCalls).toHaveLength(1); + + const putBefore = () => putImageDataCalls(surfaces).some((call) => call.image === strokes[0]!.beforeImageData); + const beforeUndoPutBefore = putBefore(); + + // Undo: removes the auto-created layer, and does NOT restore pre-stroke pixels + // (the layer's cache is gone). + engine.history.undo(); + const removeAfterUndo = dispatch.mock.calls + .map((call) => call[0] as EngineTestAction) + .filter((action) => action.type === 'removeCanvasLayers'); + expect(removeAfterUndo).toHaveLength(1); + expect(removeAfterUndo[0]).toEqual({ ids: [newLayerId], type: 'removeCanvasLayers' }); + // No new before-pixel putImageData was introduced by the undo. + expect(putBefore()).toBe(beforeUndoPutBefore); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + + // Redo: re-adds the layer and re-applies the stroke's after pixels. + engine.history.redo(); + const addAfterRedo = dispatch.mock.calls + .map((call) => call[0] as EngineTestAction) + .filter((action) => action.type === 'addCanvasLayer'); + expect(addAfterRedo).toHaveLength(2); // original auto-create + redo re-add + expect(putImageDataCalls(surfaces).some((call) => call.image === strokes[0]!.afterImageData)).toBe(true); + expect(engine.stores.canUndo.get()).toBe(true); + + engine.lifecycle.dispose(); + }); +}); + +describe('setStagedPreview', () => { + const emptyDoc = (bbox = { height: 100, width: 100, x: 0, y: 0 }): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox, + height: 100, + layers: [], + selectedLayerId: null, + version: 2, + width: 100, + }); + + /** A stub backend whose `createImageBitmap` is deferred, so decodes resolve on demand. */ + const createDeferredBitmapBackend = () => { + const base = createTestStubRasterBackend(); + const deferreds: ReturnType>[] = []; + const backend: StubRasterBackend = { + ...base, + createImageBitmap: () => { + const deferred = createDeferred(); + deferreds.push(deferred); + return deferred.promise; + }, + }; + return { + backend, + deferreds, + resolveBitmap: (index: number, width = 0, height = 0): void => + deferreds[index]!.resolve({ close: () => {}, height, width } as unknown as ImageBitmap), + }; + }; + + /** Screen-surface staged-preview draws are the 5-arg `drawImage(canvas, x, y, w, h)` calls. */ + const stagedDraws = (surface: StubRasterSurface): unknown[][] => + surface.callLog.filter((entry) => entry.op === 'drawImage' && entry.args.length === 5).map((entry) => entry.args); + + const dataUrl = (tag: string) => `data:image/png;base64,${tag}`; + + it('draws the newest decode and discards a stale one that resolves later (version race)', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const bitmaps = createDeferredBitmapBackend(); + const { store } = createReactiveStore(emptyDoc()); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); // initial (no layers, no preview) + screen.surface.callLog.length = 0; + + // Two rapid selections; the second supersedes the first. + engine.previews.setStagedPreview({ dataUrl: dataUrl('AAAA'), height: 10, width: 10 }); // decode #0 + engine.previews.setStagedPreview({ dataUrl: dataUrl('BBBB'), height: 20, width: 20 }); // decode #1 + + // The newer decode resolves first and is drawn; a frame must have been scheduled. + bitmaps.resolveBitmap(1); + await flushMicrotasks(); + expect(raf.pendingCount()).toBeGreaterThan(0); + raf.flush(); + + // The stale (older) decode resolves afterwards and must be dropped. + bitmaps.resolveBitmap(0); + await flushMicrotasks(); + raf.flush(); + + const draws = stagedDraws(screen.surface); + expect(draws.length).toBeGreaterThan(0); + // Every staged draw is the 20x20 candidate at the bbox origin; the 10x10 + // stale decode never reaches the screen. + for (const args of draws) { + expect(args.slice(1)).toEqual([0, 0, 20, 20]); + } + + engine.lifecycle.dispose(); + }); + + it('clears the preview on null', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const bitmaps = createDeferredBitmapBackend(); + const { store } = createReactiveStore(emptyDoc()); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + engine.surface.attach(screen.element, createFakeCanvas().element); + + engine.previews.setStagedPreview({ dataUrl: dataUrl('AAAA'), height: 10, width: 10 }); + bitmaps.resolveBitmap(0); + await flushMicrotasks(); + raf.flush(); + expect(stagedDraws(screen.surface).length).toBeGreaterThan(0); + + screen.surface.callLog.length = 0; + engine.previews.setStagedPreview(null); + raf.flush(); + expect(stagedDraws(screen.surface)).toHaveLength(0); + + engine.lifecycle.dispose(); + }); + + it('follows the current bbox origin, not the bbox at set time', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const bitmaps = createDeferredBitmapBackend(); + const { setDocument, store } = createReactiveStore(emptyDoc({ height: 100, width: 100, x: 0, y: 0 })); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + engine.surface.attach(screen.element, createFakeCanvas().element); + + engine.previews.setStagedPreview({ dataUrl: dataUrl('AAAA'), height: 10, width: 10 }); + bitmaps.resolveBitmap(0); + await flushMicrotasks(); + raf.flush(); + expect(stagedDraws(screen.surface).at(-1)!.slice(1)).toEqual([0, 0, 10, 10]); + + // Move the bbox (an ordinary edit, same document revision — not a replacement). + screen.surface.callLog.length = 0; + setDocument(emptyDoc({ height: 10, width: 10, x: 30, y: 40 })); + raf.flush(); + expect(stagedDraws(screen.surface).at(-1)!.slice(1)).toEqual([30, 40, 10, 10]); + + engine.lifecycle.dispose(); + }); + + it('decodes an imageName candidate through the resolver and draws it at the bbox', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(emptyDoc({ height: 100, width: 100, x: 5, y: 7 })); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + engine.surface.attach(screen.element, createFakeCanvas().element); + + engine.previews.setStagedPreview({ imageName: 'staged-candidate' }); + await flushMicrotasks(); + raf.flush(); + + expect(resolver).toHaveBeenCalledWith('staged-candidate'); + // The decoded (stub, 0-sized) surface is drawn at the current bbox origin. + expect(stagedDraws(screen.surface).at(-1)!.slice(1, 3)).toEqual([5, 7]); + + engine.lifecycle.dispose(); + }); + + it('stores an image candidate placement and renders it independently of the current bbox', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const base = createTestStubRasterBackend(); + const backend: StubRasterBackend = { + ...base, + createImageBitmap: () => Promise.resolve({ close: vi.fn(), height: 17, width: 23 } as unknown as ImageBitmap), + }; + const { setDocument, store } = createReactiveStore(emptyDoc({ height: 100, width: 100, x: 50, y: 60 })); + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + engine.surface.attach(screen.element, createFakeCanvas().element); + + engine.previews.setStagedPreview({ + imageName: 'placed-candidate', + placement: { height: 34, opacity: 0.4, width: 46, x: -8, y: 13 }, + }); + await flushMicrotasks(); + raf.flush(); + + expect(stagedDraws(screen.surface).at(-1)!.slice(1)).toEqual([-8, 13, 46, 34]); + const alphaSets = screen.surface.callLog + .filter((entry) => entry.op === 'set' && entry.args[0] === 'globalAlpha') + .map((entry) => entry.args[1]); + expect(alphaSets).toContain(0.4); + + // A placed candidate no longer follows the bbox, so moving the bbox only + // invalidates overlay chrome and must not recomposite every canvas layer. + screen.surface.callLog.length = 0; + setDocument(emptyDoc({ height: 50, width: 50, x: 2, y: 3 })); + raf.flush(); + expect(stagedDraws(screen.surface)).toHaveLength(0); + engine.lifecycle.dispose(); + }); +}); + +describe('commitRasterFilterResult', () => { + const durableImage = { height: 10, imageName: 'filtered-result', width: 10 }; + + const createReplayFaultBackend = () => { + const base = createTestStubRasterBackend(); + let fault: 'allocation' | 'draw' | null = null; + const backend: StubRasterBackend = { + ...base, + createSurface: (width, height) => { + if (fault === 'allocation') { + fault = null; + throw new Error('replay cache allocation failed'); + } + const surface = base.createSurface(width, height); + const ctx = new Proxy(surface.ctx, { + get: (target, property, receiver) => { + const value = Reflect.get(target, property, receiver); + if (property !== 'drawImage' || typeof value !== 'function') { + return value; + } + return (...args: unknown[]) => { + if (fault === 'draw') { + fault = null; + throw new Error('replay cache draw failed'); + } + return Reflect.apply(value, target, args); + }; + }, + }); + Object.defineProperty(surface, 'ctx', { value: ctx }); + return surface; + }, + }; + return { + arm: (next: 'allocation' | 'draw') => { + fault = next; + }, + backend, + }; + }; + + const filterLayer = (id = 'L'): CanvasRasterLayerContractV2 => ({ + adjustments: { brightness: 0.2, contrast: -0.1, saturation: 0.3 }, + blendMode: 'multiply', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 0.6, + source: { fill: '#fff', height: 10, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 10 }, + transform: { rotation: 15, scaleX: 2, scaleY: 3, x: 5, y: 6 }, + type: 'raster', + }); + + const filterDoc = (layers: CanvasLayerContract[] = [filterLayer()]): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId: layers[0]?.id ?? null, + version: 2, + width: 100, + }); + + it('replaces with local paint pixels, clears adjustments, and restores exact state with one history entry', async () => { + const layer = filterLayer(); + const { projectId, store } = createReducerBackedStore(filterDoc([layer])); + const backend = createRecordingRasterBackend(); + backend.createImageBitmap = vi.fn(() => Promise.resolve(recordingBitmap('filtered'))); + const engine = createCanvasEngine({ + backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const before = structuredClone(engine.document.getDocument()!.layers[0]!); + const exported = await engine.exports.exportLayerPixels(layer.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode: 'replace', + rect: { height: 10, width: 10, x: -4, y: 7 }, + }); + + expect(result).toEqual({ layerId: layer.id, status: 'committed' }); + expect(getCanvasOperations(engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + const after = engine.document.getDocument()!.layers[0]!; + const { adjustments: _adjustments, ...beforeWithoutAdjustments } = before as CanvasRasterLayerContractV2; + expect(after).toEqual({ + ...beforeWithoutAdjustments, + source: { bitmap: durableImage, offset: { x: -4, y: 7 }, type: 'paint' }, + }); + expect(after.transform).toEqual(layer.transform); + expect('adjustments' in after).toBe(false); + expect(engine.stores.canUndo.get()).toBe(true); + const forwardExport = await engine.exports.exportLayerPixels(layer.id); + expect(forwardExport.status).toBe('ok'); + if (forwardExport.status !== 'ok') { + throw new Error('expected committed pixels'); + } + const forwardSources = backend.drawSourcesFor(forwardExport.surface as StubRasterSurface); + + engine.history.undo(); + expect(engine.document.getDocument()!.layers[0]).toEqual(before); + expect(engine.stores.canUndo.get()).toBe(false); + const undoExport = await engine.exports.exportLayerPixels(layer.id); + expect(undoExport.status).toBe('ok'); + if (undoExport.status === 'ok') { + const undoSources = backend.drawSourcesFor(undoExport.surface as StubRasterSurface); + const beforeSnapshot = backend.surfaceById(undoSources.at(-1)!); + expect(beforeSnapshot).toBeDefined(); + expect(backend.drawSourcesFor(beforeSnapshot!)).toContain( + backend.surfaceId(exported.surface as StubRasterSurface) + ); + } + + engine.history.redo(); + expect(engine.document.getDocument()!.layers[0]).toEqual(after); + expect(engine.stores.canRedo.get()).toBe(false); + const redoExport = await engine.exports.exportLayerPixels(layer.id); + expect(redoExport.status).toBe('ok'); + if (redoExport.status === 'ok') { + expect(backend.drawSourcesFor(redoExport.surface as StubRasterSurface)).toEqual(forwardSources); + } + engine.lifecycle.dispose(); + }); + + it('copies filtered local pixels directly above the source and replays one structural history entry', async () => { + const source = filterLayer('source'); + const below = filterLayer('below'); + const { projectId, store } = createReducerBackedStore({ + ...filterDoc([source, below]), + selectedLayerId: below.id, + }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const sourceBefore = structuredClone(source); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode: 'copy', + rect: { height: 10, width: 10, x: 3, y: 4 }, + }); + + expect(result.status).toBe('committed'); + if (result.status !== 'committed') { + throw new Error('expected a committed copy'); + } + expect(engine.document.getDocument()!.layers.map((candidate) => candidate.id)).toEqual([ + result.layerId, + 'source', + 'below', + ]); + expect(engine.document.getDocument()!.layers.find((candidate) => candidate.id === 'source')).toEqual(sourceBefore); + const copy = engine.document.getDocument()!.layers[0]!; + expect(copy).toMatchObject({ + blendMode: source.blendMode, + isEnabled: source.isEnabled, + isLocked: false, + opacity: source.opacity, + source: { bitmap: durableImage, offset: { x: 3, y: 4 }, type: 'paint' }, + transform: source.transform, + type: 'raster', + }); + expect('adjustments' in copy).toBe(false); + expect((await engine.exports.exportLayerPixels(result.layerId)).status).toBe('ok'); + + engine.history.undo(); + expect(engine.document.getDocument()!.layers.map((candidate) => candidate.id)).toEqual(['source', 'below']); + expect(engine.document.getDocument()!.selectedLayerId).toBe(below.id); + expect(engine.stores.canUndo.get()).toBe(false); + engine.history.redo(); + expect(engine.document.getDocument()!.layers.map((candidate) => candidate.id)).toEqual([ + result.layerId, + 'source', + 'below', + ]); + expect(engine.document.getDocument()!.selectedLayerId).toBe(result.layerId); + expect(engine.document.getDocument()!.layers[0]).toEqual(copy); + expect(engine.stores.canRedo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('atomically applies a filter and nonzero local origin to a control layer with exact undo/redo', async () => { + const source: CanvasLayerContract = { + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + blendMode: 'normal', + filter: { settings: { radius: 1 }, type: 'old_filter' }, + id: 'control', + isEnabled: true, + isLocked: false, + name: 'Control', + opacity: 1, + source: { image: { height: 10, imageName: 'control-source', width: 10 }, type: 'image' }, + transform: { rotation: 12, scaleX: 2, scaleY: 3, x: 40, y: 50 }, + type: 'control', + withTransparencyEffect: true, + }; + const { projectId, store } = createReducerBackedStore(filterDoc([source])); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const before = structuredClone(source); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable control export'); + } + + const result = await engine.layers.commitRasterFilterResult({ + filter: { settings: { radius: 8 }, type: 'content_shuffle' }, + guard: exported.guard, + image: durableImage, + mode: 'replace', + rect: { height: 10, width: 10, x: 7, y: -3 }, + target: 'apply', + }); + + expect(result).toEqual({ layerId: source.id, status: 'committed' }); + const after = engine.document.getDocument()!.layers[0]!; + expect(after).toMatchObject({ + filter: { settings: { radius: 8 }, type: 'content_shuffle' }, + source: { bitmap: durableImage, offset: { x: 7, y: -3 }, type: 'paint' }, + transform: source.transform, + type: 'control', + }); + engine.history.undo(); + expect(engine.document.getDocument()!.layers[0]).toEqual(before); + engine.history.redo(); + expect(engine.document.getDocument()!.layers[0]).toEqual(after); + engine.lifecycle.dispose(); + }); + + it.each(['raster', 'control'] as const)( + 'saves a filtered result as a %s layer above a control source', + async (target) => { + const source: CanvasLayerContract = { + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + blendMode: 'normal', + id: 'control', + isEnabled: true, + isLocked: false, + name: 'Control', + opacity: 1, + source: { image: { height: 10, imageName: 'control-source', width: 10 }, type: 'image' }, + transform: { rotation: 12, scaleX: 2, scaleY: 3, x: 40, y: 50 }, + type: 'control', + withTransparencyEffect: true, + }; + const { projectId, store } = createReducerBackedStore(filterDoc([source])); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable control export'); + } + + const result = await engine.layers.commitRasterFilterResult({ + filter: { settings: {}, type: 'canny_edge_detection' }, + guard: exported.guard, + image: durableImage, + mode: 'copy', + rect: { height: 10, width: 10, x: 7, y: -3 }, + target, + }); + + expect(result.status).toBe('committed'); + expect(engine.document.getDocument()!.layers.map((layer) => layer.type)).toEqual([target, 'control']); + expect(engine.document.getDocument()!.layers[0]).toMatchObject({ + filter: { settings: {}, type: 'canny_edge_detection' }, + source: { offset: { x: 7, y: -3 }, type: 'paint' }, + transform: source.transform, + }); + engine.lifecycle.dispose(); + } + ); + + it('preserves raster source placement when saving the filtered result as control', async () => { + const source = filterLayer('source'); + const { projectId, store } = createReducerBackedStore(filterDoc([source])); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable raster export'); + } + + const result = await engine.layers.commitRasterFilterResult({ + filter: { settings: {}, type: 'canny_edge_detection' }, + guard: exported.guard, + image: durableImage, + mode: 'copy', + rect: { height: 10, width: 10, x: -4, y: 7 }, + target: 'control', + }); + + expect(result.status).toBe('committed'); + expect(engine.document.getDocument()!.layers[0]).toMatchObject({ + source: { offset: { x: -4, y: 7 }, type: 'paint' }, + transform: source.transform, + type: 'control', + }); + engine.lifecycle.dispose(); + }); + + it.each([ + { base: 'z-image', expectedKind: 'z_image_control' }, + { base: 'sd-1', expectedKind: 'controlnet' }, + { base: 'flux', expectedKind: 'controlnet' }, + ] as const)( + 'uses $expectedKind defaults when Save Filter As Control runs under $base', + async ({ base, expectedKind }) => { + const source = filterLayer('source'); + const { projectId, store } = createReducerBackedStore(filterDoc([source]), base); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable raster export'); + } + + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode: 'copy', + rect: exported.rect, + target: 'control', + }); + + expect(result.status).toBe('committed'); + const created = engine.document.getDocument()!.layers[0]; + expect(created?.type === 'control' ? created.adapter.kind : null).toBe(expectedKind); + engine.lifecycle.dispose(); + } + ); + + it.each(['allocation', 'draw'] as const)( + 'keeps replace undo retryable and exact when detached cache %s fails', + async (failure) => { + const source = filterLayer('source'); + const { projectId, store } = createReducerBackedStore(filterDoc([source])); + const faults = createReplayFaultBackend(); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: faults.backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode: 'replace', + rect: exported.rect, + }); + expect(result.status).toBe('committed'); + const expectedDocument = structuredClone(engine.document.getDocument()!); + const expectedCache = await engine.exports.exportLayerPixels(source.id); + if (expectedCache.status !== 'ok') { + throw new Error('expected committed cache pixels'); + } + const expectedCalls = structuredClone((expectedCache.surface as StubRasterSurface).callLog); + const adjustedDeletesBefore = adjustedSurfaceCacheDeletes.length; + const thumbnailListener = vi.fn(); + const unsubscribeThumbnail = engine.stores.thumbnailVersion.subscribe(thumbnailListener); + bitmapStore.markLayerDirty.mockClear(); + (store.dispatch as Mock).mockClear(); + faults.arm(failure); + + expect(() => engine.history.undo()).toThrow(`replay cache ${failure} failed`); + + expect(engine.document.getDocument()).toEqual(expectedDocument); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(thumbnailListener).not.toHaveBeenCalled(); + expect(adjustedSurfaceCacheDeletes).toHaveLength(adjustedDeletesBefore); + const afterFailure = await engine.exports.exportLayerPixels(source.id); + expect(afterFailure.status).toBe('ok'); + if (afterFailure.status === 'ok') { + expect(afterFailure.surface).toBe(expectedCache.surface); + expect(afterFailure.rect).toEqual(expectedCache.rect); + expect(afterFailure.guard.cacheVersion).toBe(expectedCache.guard.cacheVersion); + expect((afterFailure.surface as StubRasterSurface).callLog).toEqual(expectedCalls); + } + + engine.history.undo(); + expect(engine.document.getDocument()!.layers[0]).toEqual(source); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + unsubscribeThumbnail(); + engine.lifecycle.dispose(); + } + ); + + it.each(['allocation', 'draw'] as const)( + 'keeps replace redo retryable and exact when detached cache %s fails', + async (failure) => { + const source = filterLayer('source'); + const { projectId, store } = createReducerBackedStore(filterDoc([source])); + const faults = createReplayFaultBackend(); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: faults.backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode: 'replace', + rect: exported.rect, + }); + expect(result.status).toBe('committed'); + const filteredDocument = structuredClone(engine.document.getDocument()!); + engine.history.undo(); + const expectedDocument = structuredClone(engine.document.getDocument()!); + const expectedCache = await engine.exports.exportLayerPixels(source.id); + if (expectedCache.status !== 'ok') { + throw new Error('expected restored source pixels'); + } + const expectedCalls = structuredClone((expectedCache.surface as StubRasterSurface).callLog); + const adjustedDeletesBefore = adjustedSurfaceCacheDeletes.length; + const thumbnailListener = vi.fn(); + const unsubscribeThumbnail = engine.stores.thumbnailVersion.subscribe(thumbnailListener); + bitmapStore.markLayerDirty.mockClear(); + (store.dispatch as Mock).mockClear(); + faults.arm(failure); + + expect(() => engine.history.redo()).toThrow(`replay cache ${failure} failed`); + + expect(engine.document.getDocument()).toEqual(expectedDocument); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(thumbnailListener).not.toHaveBeenCalled(); + expect(adjustedSurfaceCacheDeletes).toHaveLength(adjustedDeletesBefore); + const afterFailure = await engine.exports.exportLayerPixels(source.id); + expect(afterFailure.status).toBe('ok'); + if (afterFailure.status === 'ok') { + expect(afterFailure.surface).toBe(expectedCache.surface); + expect(afterFailure.rect).toEqual(expectedCache.rect); + expect(afterFailure.guard.cacheVersion).toBe(expectedCache.guard.cacheVersion); + expect((afterFailure.surface as StubRasterSurface).callLog).toEqual(expectedCalls); + } + + engine.history.redo(); + expect(engine.document.getDocument()).toEqual(filteredDocument); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + unsubscribeThumbnail(); + engine.lifecycle.dispose(); + } + ); + + it('discards obsolete persistence and keeps the exact durable image ref on replace redo', async () => { + const source = filterLayer('source'); + const { projectId, store } = createReducerBackedStore(filterDoc([source])); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + + await expect( + engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode: 'replace', + rect: exported.rect, + }) + ).resolves.toEqual({ layerId: source.id, status: 'committed' }); + expect(bitmapStore.discardLayer).toHaveBeenCalledTimes(1); + + engine.history.undo(); + bitmapStore.markLayerDirty.mockClear(); + engine.history.redo(); + + expect(bitmapStore.discardLayer).toHaveBeenCalledTimes(2); + expect(bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + const redone = engine.document.getDocument()!.layers[0]; + expect(redone).toMatchObject({ source: { bitmap: durableImage, type: 'paint' } }); + if (redone?.type === 'raster' && redone.source.type === 'paint') { + expect(redone.source.bitmap).toEqual(durableImage); + } + engine.lifecycle.dispose(); + }); + + describe('paint persistence cancellation', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + const drainUntil = async (predicate: () => boolean, maxTicks = 50): Promise => { + for (let i = 0; i < maxTicks && !predicate(); i += 1) { + await Promise.resolve(); + } + }; + + const createRealBitmapStoreHarness = async ( + uploadImage: (blob: Blob) => Promise<{ height: number; imageName: string; width: number }> + ) => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const source: CanvasRasterLayerContractV2 = { + ...filterLayer('source'), + source: { + bitmap: { height: 10, imageName: 'before-paint', width: 10 }, + offset: { x: 0, y: 0 }, + type: 'paint', + }, + }; + const { projectId, store } = createReducerBackedStore(filterDoc([source])); + const backend = { + ...createRecordingRasterBackend(), + encodeSurface: vi.fn(() => Promise.resolve(new Blob(['paint-pixels'], { type: 'image/png' }))), + }; + let bitmapCall = 0; + backend.createImageBitmap = vi.fn(() => + Promise.resolve(recordingBitmap(bitmapCall++ === 0 ? 'before-paint' : 'filtered-result')) + ); + let placed: { offset: { x: number; y: number }; surface: RasterSurface } | null = null; + const bitmapStore = createBitmapStore({ + debounceMs: 1500, + dispatch: createTestMutationPort(store, projectId).dispatch, + encodeSurface: () => Promise.resolve(new Blob(['unpersisted-paint'], { type: 'image/png' })), + getLayerSource: (layerId) => { + const layer = store + .getState() + .projects.find((project) => project.id === projectId) + ?.canvas.document.layers.find((candidate) => candidate.id === layerId); + return layer?.type === 'raster' || layer?.type === 'control' ? layer.source : null; + }, + getLayerSurface: () => placed, + hashBlob: (blob) => blob.text(), + maxUploadAttempts: 1, + retryDelaysMs: [], + sleep: () => Promise.resolve(), + uploadImage, + }); + const discardLayer = vi.spyOn(bitmapStore, 'discardLayer'); + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const initial = await engine.exports.exportLayerPixels(source.id); + if (initial.status !== 'ok') { + throw new Error('expected persisted paint pixels'); + } + engine.selection.selectAll(); + engine.selection.fillSelection(); + const refreshPlacement = async (layerId = source.id) => { + const exported = await engine.exports.exportLayerPixels(layerId); + if (exported.status !== 'ok') { + throw new Error('expected live paint pixels'); + } + placed = { + offset: { x: exported.rect.x, y: exported.rect.y }, + surface: exported.surface, + }; + return exported; + }; + const exported = await refreshPlacement(); + return { backend, bitmapStore, discardLayer, engine, exported, projectId, refreshPlacement, source, store }; + }; + + const bitmapUpdateActions = (store: ReturnType['store']) => + (store.dispatch as Mock).mock.calls + .map((call) => call[0] as EngineTestAction) + .filter((action) => action.type === 'updateCanvasLayerSource' && action.id === 'source'); + + it('cancels pending debounced live-paint persistence before durable replace publication', async () => { + vi.useFakeTimers(); + const uploadImage = vi.fn(() => Promise.resolve({ height: 10, imageName: 'stale-pending-paint', width: 10 })); + const harness = await createRealBitmapStoreHarness(uploadImage); + + await expect( + harness.engine.layers.commitRasterFilterResult({ + guard: harness.exported.guard, + image: durableImage, + mode: 'replace', + rect: harness.exported.rect, + }) + ).resolves.toEqual({ layerId: harness.source.id, status: 'committed' }); + const committed = structuredClone(harness.engine.document.getDocument()!); + + await vi.advanceTimersByTimeAsync(1500); + await harness.bitmapStore.flushPendingUploads(); + + expect(harness.discardLayer).toHaveBeenCalledWith(harness.source.id); + expect(uploadImage).not.toHaveBeenCalled(); + expect(bitmapUpdateActions(harness.store)).toHaveLength(0); + expect(harness.engine.document.getDocument()).toEqual(committed); + expect(harness.engine.document.getDocument()!.layers[0]).toMatchObject({ + source: { bitmap: durableImage, type: 'paint' }, + }); + const cache = await harness.engine.exports.exportLayerPixels(harness.source.id); + expect(cache.status).toBe('ok'); + if (cache.status === 'ok') { + expect(drawGraphContains(cache.surface as StubRasterSurface, harness.backend, 'bitmap-filtered-result')).toBe( + true + ); + } + + const reloadDocument = structuredClone(harness.engine.document.getDocument()!); + harness.engine.lifecycle.dispose(); + const reloadStore = createReducerBackedStore(reloadDocument); + const reloadResolver = vi.fn(() => Promise.resolve(new Blob())); + const reloadEngine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: reloadResolver, + projectId: reloadStore.projectId, + store: reloadStore.store, + }); + expect((await reloadEngine.exports.exportLayerPixels(harness.source.id)).status).toBe('ok'); + expect(reloadResolver).toHaveBeenCalledWith(durableImage.imageName, expect.any(AbortSignal)); + reloadEngine.lifecycle.dispose(); + }); + + it('invalidates a deferred pre-filter upload before it can overwrite the durable replace', async () => { + vi.useFakeTimers(); + const uploaded = createDeferred<{ height: number; imageName: string; width: number }>(); + const uploadImage = vi.fn(() => uploaded.promise); + const harness = await createRealBitmapStoreHarness(uploadImage); + const barrier = harness.bitmapStore.flushPendingUploads(); + await drainUntil(() => uploadImage.mock.calls.length === 1); + expect(uploadImage).toHaveBeenCalledOnce(); + + await expect( + harness.engine.layers.commitRasterFilterResult({ + guard: harness.exported.guard, + image: durableImage, + mode: 'replace', + rect: harness.exported.rect, + }) + ).resolves.toEqual({ layerId: harness.source.id, status: 'committed' }); + const committed = structuredClone(harness.engine.document.getDocument()!); + uploaded.resolve({ height: 10, imageName: 'stale-in-flight-paint', width: 10 }); + await barrier; + await vi.advanceTimersByTimeAsync(3000); + + expect(bitmapUpdateActions(harness.store)).toHaveLength(0); + expect(harness.engine.document.getDocument()).toEqual(committed); + const cache = await harness.engine.exports.exportLayerPixels(harness.source.id); + expect(cache.status).toBe('ok'); + if (cache.status === 'ok') { + expect(drawGraphContains(cache.surface as StubRasterSurface, harness.backend, 'bitmap-filtered-result')).toBe( + true + ); + } + harness.engine.lifecycle.dispose(); + }); + + it('redo discards a deferred upload from the restored unpersisted paint snapshot', async () => { + vi.useFakeTimers(); + const uploaded = createDeferred<{ height: number; imageName: string; width: number }>(); + const uploadImage = vi.fn(() => uploaded.promise); + const harness = await createRealBitmapStoreHarness(uploadImage); + await expect( + harness.engine.layers.commitRasterFilterResult({ + guard: harness.exported.guard, + image: durableImage, + mode: 'replace', + rect: harness.exported.rect, + }) + ).resolves.toEqual({ layerId: harness.source.id, status: 'committed' }); + const committed = structuredClone(harness.engine.document.getDocument()!); + + harness.engine.history.undo(); + await harness.refreshPlacement(); + const barrier = harness.bitmapStore.flushPendingUploads(); + await drainUntil(() => uploadImage.mock.calls.length === 1); + expect(uploadImage).toHaveBeenCalledOnce(); + + harness.engine.history.redo(); + await harness.refreshPlacement(); + expect(harness.engine.document.getDocument()).toEqual(committed); + uploaded.resolve({ height: 10, imageName: 'stale-undo-paint', width: 10 }); + await barrier; + await vi.advanceTimersByTimeAsync(3000); + + expect(harness.discardLayer).toHaveBeenCalledTimes(2); + expect(bitmapUpdateActions(harness.store)).toHaveLength(0); + expect(harness.engine.document.getDocument()).toEqual(committed); + expect(harness.engine.stores.canUndo.get()).toBe(true); + expect(harness.engine.stores.canRedo.get()).toBe(false); + const redone = harness.engine.document.getDocument()!.layers[0]; + expect(redone).toMatchObject({ source: { bitmap: durableImage, type: 'paint' } }); + harness.engine.lifecycle.dispose(); + }); + + it('copy undo generation-cancels a deferred upload before redo reuses the layer id', async () => { + vi.useFakeTimers(); + const uploaded = createDeferred<{ height: number; imageName: string; width: number }>(); + const uploadImage = vi.fn(() => uploaded.promise); + const harness = await createRealBitmapStoreHarness(uploadImage); + // The harness creates live source paint to establish a guarded export; + // this test targets only persistence owned by the subsequently-created copy. + harness.bitmapStore.discardLayer(harness.source.id); + const copied = await harness.engine.layers.commitRasterFilterResult({ + guard: harness.exported.guard, + image: durableImage, + mode: 'copy', + rect: harness.exported.rect, + }); + if (copied.status !== 'committed') { + throw new Error('expected a committed filtered copy'); + } + const durableCopy = structuredClone( + harness.engine.document.getDocument()!.layers.find((layer) => layer.id === copied.layerId)! + ); + + harness.engine.selection.selectAll(); + harness.engine.selection.fillSelection(); + await harness.refreshPlacement(copied.layerId); + const barrier = harness.bitmapStore.flushPendingUploads(); + await drainUntil(() => uploadImage.mock.calls.length === 1); + expect(uploadImage).toHaveBeenCalledOnce(); + + // Undo the paint edit, then undo the copy itself. Redo reuses the exact + // layer id while the pre-undo upload is still unresolved. + harness.engine.history.undo(); + await harness.refreshPlacement(copied.layerId); + harness.engine.history.undo(); + expect((await harness.engine.exports.exportLayerPixels(copied.layerId)).status).toBe('missing'); + harness.engine.history.redo(); + expect(harness.engine.document.getDocument()!.layers.find((layer) => layer.id === copied.layerId)).toEqual( + durableCopy + ); + + uploaded.resolve({ height: 10, imageName: 'stale-copy-paint', width: 10 }); + await barrier; + await vi.advanceTimersByTimeAsync(3000); + + const staleUpdates = (harness.store.dispatch as Mock).mock.calls + .map((call) => call[0] as EngineTestAction) + .filter((action) => action.type === 'updateCanvasLayerSource' && action.id === copied.layerId); + expect(staleUpdates).toHaveLength(0); + expect(harness.discardLayer).toHaveBeenCalledWith(copied.layerId); + expect(harness.engine.document.getDocument()!.layers.find((layer) => layer.id === copied.layerId)).toEqual( + durableCopy + ); + expect(harness.engine.stores.canUndo.get()).toBe(true); + const cache = await harness.engine.exports.exportLayerPixels(copied.layerId); + expect(cache.status).toBe('ok'); + if (cache.status === 'ok') { + expect(drawGraphContains(cache.surface as StubRasterSurface, harness.backend, 'bitmap-filtered-result')).toBe( + true + ); + } + harness.engine.lifecycle.dispose(); + }); + }); + + it.each(['allocation', 'draw'] as const)( + 'keeps copy redo retryable and exact when detached cache %s fails', + async (failure) => { + const source = filterLayer('source'); + const selected = filterLayer('selected'); + const beforeDocument = { ...filterDoc([source, selected]), selectedLayerId: selected.id }; + const { projectId, store } = createReducerBackedStore(beforeDocument); + const faults = createReplayFaultBackend(); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: faults.backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode: 'copy', + rect: exported.rect, + }); + if (result.status !== 'committed') { + throw new Error('expected a committed copy'); + } + const copy = structuredClone(engine.document.getDocument()!.layers[0]!); + engine.history.undo(); + expect(engine.document.getDocument()).toEqual(beforeDocument); + const adjustedDeletesBefore = adjustedSurfaceCacheDeletes.length; + const thumbnailListener = vi.fn(); + const unsubscribeThumbnail = engine.stores.thumbnailVersion.subscribe(thumbnailListener); + bitmapStore.markLayerDirty.mockClear(); + (store.dispatch as Mock).mockClear(); + faults.arm(failure); + + expect(() => engine.history.redo()).toThrow(`replay cache ${failure} failed`); + + expect(engine.document.getDocument()).toEqual(beforeDocument); + expect(engine.document.getDocument()!.selectedLayerId).toBe(selected.id); + expect((await engine.exports.exportLayerPixels(result.layerId)).status).toBe('missing'); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(thumbnailListener).not.toHaveBeenCalled(); + expect(adjustedSurfaceCacheDeletes).toHaveLength(adjustedDeletesBefore); + + engine.history.redo(); + expect(engine.document.getDocument()!.layers[0]).toEqual(copy); + expect(engine.document.getDocument()!.selectedLayerId).toBe(result.layerId); + expect((await engine.exports.exportLayerPixels(result.layerId)).status).toBe('ok'); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + unsubscribeThumbnail(); + engine.lifecycle.dispose(); + } + ); + + const createGuardHarness = async ( + imageResolver: (imageName: string, signal?: AbortSignal) => Promise = () => Promise.resolve(new Blob()) + ) => { + const layer = filterLayer(); + const document = filterDoc([layer]); + const reactive = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver, + projectId: 'p1', + store: reactive.store, + }); + const exported = await engine.exports.exportLayerPixels(layer.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + (reactive.store.dispatch as Mock).mockClear(); + const commit = (signal?: AbortSignal) => + engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode: 'replace', + rect: exported.rect, + signal, + }); + return { ...reactive, commit, document, engine, guard: exported.guard, layer }; + }; + + const createPendingGuardHarness = async () => { + const decoded = createDeferred(); + const harness = await createGuardHarness(() => decoded.promise); + return { ...harness, decoded, pending: harness.commit() }; + }; + + it('returns failed without mutation when the durable image cannot be decoded', async () => { + const harness = await createGuardHarness(() => Promise.reject(new Error('decode failed'))); + + await expect(harness.commit()).resolves.toEqual({ message: 'decode failed', status: 'failed' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it('returns aborted without starting durable-image decode when already cancelled', async () => { + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const harness = await createGuardHarness(resolver); + const controller = new AbortController(); + controller.abort(); + + await expect(harness.commit(controller.signal)).resolves.toEqual({ status: 'aborted' }); + expect(resolver).not.toHaveBeenCalled(); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it('passes the exact cancellation signal into durable-image resolution', async () => { + const resolver = vi.fn((_imageName: string, _signal?: AbortSignal) => Promise.resolve(new Blob())); + const layer = filterLayer(); + const reducer = createReducerBackedStore(filterDoc([layer])); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: resolver, + projectId: reducer.projectId, + store: reducer.store, + }); + const exported = await engine.exports.exportLayerPixels(layer.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + const controller = new AbortController(); + + await expect( + engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode: 'replace', + rect: exported.rect, + signal: controller.signal, + }) + ).resolves.toMatchObject({ status: 'committed' }); + expect(resolver).toHaveBeenCalledWith(durableImage.imageName, controller.signal); + engine.lifecycle.dispose(); + }); + + it('maps an abort rejection from durable-image resolution to aborted without mutation', async () => { + const resolver = vi.fn(() => Promise.reject(new DOMException('cancelled', 'AbortError'))); + const harness = await createGuardHarness(resolver); + const controller = new AbortController(); + + await expect(harness.commit(controller.signal)).resolves.toEqual({ status: 'aborted' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it('returns aborted without mutation when cancellation occurs during durable-image decode', async () => { + const decoded = createDeferred(); + const harness = await createGuardHarness(() => decoded.promise); + const controller = new AbortController(); + + const pending = harness.commit(controller.signal); + controller.abort(); + decoded.resolve(new Blob()); + + await expect(pending).resolves.toEqual({ status: 'aborted' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it('does not publish when an operation starts during durable-image decode', async () => { + const harness = await createPendingGuardHarness(); + const cleanupPreview = vi.fn(); + getCanvasOperations(harness.engine).controller.start({ + cleanupPreview, + guard: harness.guard, + identity: { kind: 'filter', layerId: harness.layer.id, projectId: 'p1' }, + }); + + harness.decoded.resolve(new Blob()); + + await expect(harness.pending).resolves.toEqual({ status: 'busy' }); + expect(harness.engine.document.getDocument()).toEqual(harness.document); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + expect(cleanupPreview).not.toHaveBeenCalled(); + expect(getCanvasOperations(harness.engine).controller.getSnapshot()).toMatchObject({ + identity: { kind: 'filter' }, + status: 'active', + }); + harness.engine.lifecycle.dispose(); + }); + + it('returns missing without mutation when the source is deleted', async () => { + const harness = await createPendingGuardHarness(); + harness.setDocument({ ...harness.document, layers: [] }); + harness.decoded.resolve(new Blob()); + + await expect(harness.pending).resolves.toEqual({ status: 'missing' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it('returns stale without mutation when the source contract changes', async () => { + const harness = await createPendingGuardHarness(); + harness.setDocument({ ...harness.document, layers: [{ ...harness.layer, opacity: 0.2 }] }); + harness.decoded.resolve(new Blob()); + + await expect(harness.pending).resolves.toEqual({ status: 'stale' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it('returns locked without mutation when the source becomes locked', async () => { + const harness = await createPendingGuardHarness(); + harness.setDocument({ ...harness.document, layers: [{ ...harness.layer, isLocked: true }] }); + harness.decoded.resolve(new Blob()); + + await expect(harness.pending).resolves.toEqual({ status: 'locked' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it('returns stale without mutation when the guarded source changes layer type', async () => { + const harness = await createPendingGuardHarness(); + const { adjustments: _adjustments, ...base } = harness.layer; + const control: CanvasLayerContract = { + ...base, + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + type: 'control', + withTransparencyEffect: false, + }; + harness.setDocument({ ...harness.document, layers: [control] }); + harness.decoded.resolve(new Blob()); + + await expect(harness.pending).resolves.toEqual({ status: 'stale' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it('returns stale without mutation after the source cache version changes', async () => { + const harness = await createPendingGuardHarness(); + await harness.engine.diagnostics.clearCaches(); + harness.decoded.resolve(new Blob()); + + await expect(harness.pending).resolves.toEqual({ status: 'stale' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it('returns stale without mutation after document replacement reuses the source layer', async () => { + const harness = await createPendingGuardHarness(); + harness.setDocument({ ...harness.document, height: 200, width: 200 }, 1); + harness.decoded.resolve(new Blob()); + + await expect(harness.pending).resolves.toEqual({ status: 'stale' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it('commits to the bound project after the active project changes', async () => { + const harness = await createPendingGuardHarness(); + harness.setActiveProjectId('p2'); + harness.decoded.resolve(new Blob()); + + await expect(harness.pending).resolves.toEqual({ layerId: 'L', status: 'committed' }); + expect(harness.store.dispatch).toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(true); + harness.engine.lifecycle.dispose(); + }); + + it('returns busy without mutation while a pointer gesture is open', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const harness = await createPendingGuardHarness(); + const screen = createInputCanvas(); + const overlay = createInputCanvas(); + harness.engine.surface.attach(screen.element, overlay.element); + overlay.fire('pointerdown', pointerAt(5, 5)); + harness.decoded.resolve(new Blob()); + + await expect(harness.pending).resolves.toEqual({ status: 'busy' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it.each(['replace', 'copy'] as const)( + 'leaves document, cache, selection, history, and side effects exact when %s cache preparation fails', + async (mode) => { + const baseBackend = createTestStubRasterBackend(); + let successfulCreatesBeforeFailure: number | null = null; + const backend: StubRasterBackend = { + ...baseBackend, + createSurface: (width, height) => { + if (successfulCreatesBeforeFailure !== null) { + if (successfulCreatesBeforeFailure === 0) { + throw new Error('filter cache allocation failed'); + } + successfulCreatesBeforeFailure -= 1; + } + return baseBackend.createSurface(width, height); + }, + }; + const source = filterLayer('source'); + const selected = filterLayer('selected'); + const beforeDocument = { ...filterDoc([source, selected]), selectedLayerId: selected.id }; + const { projectId, store } = createReducerBackedStore(beforeDocument); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + const cacheSurface = exported.surface; + const cacheCalls = structuredClone((cacheSurface as StubRasterSurface).callLog); + const thumbnailVersion = engine.stores.thumbnailVersion.get(source.id); + const thumbnailListener = vi.fn(); + const unsubscribeThumbnail = engine.stores.thumbnailVersion.subscribe(thumbnailListener); + const adjustedDeletesBefore = adjustedSurfaceCacheDeletes.length; + bitmapStore.markLayerDirty.mockClear(); + (store.dispatch as Mock).mockClear(); + + // Commit allocates the decoded candidate first. Replace also captures its + // before-pixels snapshot. The following allocation is cache preparation. + successfulCreatesBeforeFailure = mode === 'replace' ? 2 : 1; + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode, + rect: exported.rect, + }); + successfulCreatesBeforeFailure = null; + + expect(result).toEqual({ message: 'filter cache allocation failed', status: 'failed' }); + expect(engine.document.getDocument()).toEqual(beforeDocument); + expect(engine.document.getDocument()!.selectedLayerId).toBe(selected.id); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(false); + expect(bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(thumbnailListener).not.toHaveBeenCalled(); + expect(engine.stores.thumbnailVersion.get(source.id)).toBe(thumbnailVersion); + expect(adjustedSurfaceCacheDeletes).toHaveLength(adjustedDeletesBefore); + + const afterExport = await engine.exports.exportLayerPixels(source.id); + expect(afterExport.status).toBe('ok'); + if (afterExport.status === 'ok') { + expect(afterExport.surface).toBe(cacheSurface); + expect(afterExport.rect).toEqual(exported.rect); + expect(afterExport.guard.cacheVersion).toBe(exported.guard.cacheVersion); + expect((afterExport.surface as StubRasterSurface).callLog).toEqual(cacheCalls); + } + + unsubscribeThumbnail(); + engine.lifecycle.dispose(); + } + ); + + it.each(['replace', 'copy'] as const)( + 'leaves document, cache, selection, history, and side effects exact when %s cache preparation draw fails', + async (mode) => { + const baseBackend = createTestStubRasterBackend(); + let successfulDrawsBeforeFailure: number | null = null; + const backend: StubRasterBackend = { + ...baseBackend, + createSurface: (width, height) => { + const surface = baseBackend.createSurface(width, height); + const ctx = new Proxy(surface.ctx, { + get: (target, property, receiver) => { + const value = Reflect.get(target, property, receiver); + if (property !== 'drawImage' || typeof value !== 'function') { + return value; + } + return (...args: unknown[]) => { + if (successfulDrawsBeforeFailure !== null) { + if (successfulDrawsBeforeFailure === 0) { + throw new Error('filter cache draw failed'); + } + successfulDrawsBeforeFailure -= 1; + } + return Reflect.apply(value, target, args); + }; + }, + }); + Object.defineProperty(surface, 'ctx', { value: ctx }); + return surface; + }, + }; + const source = filterLayer('source'); + const selected = filterLayer('selected'); + const beforeDocument = { ...filterDoc([source, selected]), selectedLayerId: selected.id }; + const { projectId, store } = createReducerBackedStore(beforeDocument); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + const cacheSurface = exported.surface; + const cacheCalls = structuredClone((cacheSurface as StubRasterSurface).callLog); + const thumbnailVersion = engine.stores.thumbnailVersion.get(source.id); + const thumbnailListener = vi.fn(); + const unsubscribeThumbnail = engine.stores.thumbnailVersion.subscribe(thumbnailListener); + const adjustedDeletesBefore = adjustedSurfaceCacheDeletes.length; + bitmapStore.markLayerDirty.mockClear(); + (store.dispatch as Mock).mockClear(); + + // Commit draws the decoded candidate first. Replace also captures its + // before-pixels snapshot. The following draw prepares the detached cache. + successfulDrawsBeforeFailure = mode === 'replace' ? 2 : 1; + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode, + rect: exported.rect, + }); + successfulDrawsBeforeFailure = null; + + expect(result).toEqual({ message: 'filter cache draw failed', status: 'failed' }); + expect(engine.document.getDocument()).toEqual(beforeDocument); + expect(engine.document.getDocument()!.selectedLayerId).toBe(selected.id); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(false); + expect(bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(thumbnailListener).not.toHaveBeenCalled(); + expect(engine.stores.thumbnailVersion.get(source.id)).toBe(thumbnailVersion); + expect(adjustedSurfaceCacheDeletes).toHaveLength(adjustedDeletesBefore); + + const afterExport = await engine.exports.exportLayerPixels(source.id); + expect(afterExport.status).toBe('ok'); + if (afterExport.status === 'ok') { + expect(afterExport.surface).toBe(cacheSurface); + expect(afterExport.rect).toEqual(exported.rect); + expect(afterExport.guard.cacheVersion).toBe(exported.guard.cacheVersion); + expect((afterExport.surface as StubRasterSurface).callLog).toEqual(cacheCalls); + } + + unsubscribeThumbnail(); + engine.lifecycle.dispose(); + } + ); + + it.each(['replace', 'copy'] as const)( + 'commits %s after dispatch even when render scheduling throws, without redundant persistence', + async (mode) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const source = filterLayer('source'); + const { projectId, store } = createReducerBackedStore(filterDoc([source])); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + engine.surface.attach(createFakeCanvas().element, createFakeCanvas().element); + raf.flush(); + bitmapStore.markLayerDirty.mockImplementation(() => { + throw new Error('unexpected filter result persistence'); + }); + vi.stubGlobal('requestAnimationFrame', () => { + throw new Error('render scheduling failed'); + }); + + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode, + rect: exported.rect, + }); + + if (result.status !== 'committed') { + throw new Error(`expected a committed result, received ${JSON.stringify(result)}`); + } + expect(result).toMatchObject({ status: 'committed' }); + expect(store.dispatch).toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(true); + expect(bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + engine.lifecycle.dispose(); + } + ); + + it.each(['replace', 'copy'] as const)( + 'commits %s when a thumbnail observer throws, without redundant persistence', + async (mode) => { + const source = filterLayer('source'); + const { projectId, store } = createReducerBackedStore(filterDoc([source])); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + const unsubscribeThumbnail = engine.stores.thumbnailVersion.subscribe(() => { + throw new Error('thumbnail observer failed'); + }); + bitmapStore.markLayerDirty.mockImplementation(() => { + throw new Error('unexpected filter result persistence'); + }); + + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode, + rect: exported.rect, + }); + + expect(result).toMatchObject({ status: 'committed' }); + expect(store.dispatch).toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(true); + expect(bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + unsubscribeThumbnail(); + engine.lifecycle.dispose(); + } + ); + + it.each(['replace', 'copy'] as const)( + 'commits %s when a document observer throws after the reducer applies the mutation', + async (mode) => { + const source = filterLayer('source'); + const { projectId, store } = createReducerBackedStore(filterDoc([source])); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + const unsubscribeFault = store.subscribe(() => { + throw new Error('document observer failed'); + }); + + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode, + rect: exported.rect, + }); + + expect(result).toMatchObject({ status: 'committed' }); + expect(engine.stores.canUndo.get()).toBe(true); + expect(bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + if (result.status === 'committed') { + expect((await engine.exports.exportLayerPixels(result.layerId)).status).toBe('ok'); + } + unsubscribeFault(); + engine.lifecycle.dispose(); + } + ); + + it('refreshes the mirror and renders the filtered result when an earlier document observer interrupts dispatch', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const source = filterLayer('source'); + const { projectId, store } = createReducerBackedStore(filterDoc([source])); + const unsubscribeFault = store.subscribe(() => { + throw new Error('earlier document observer failed'); + }); + const backend = createRecordingRasterBackend(); + backend.createImageBitmap = vi.fn(() => Promise.resolve(recordingBitmap('filtered-result'))); + const engine = createCanvasEngine({ + backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode: 'replace', + rect: exported.rect, + }); + unsubscribeFault(); + + expect(result).toEqual({ layerId: source.id, status: 'committed' }); + const reducerDocument = store.getState().projects.find((project) => project.id === projectId)!.canvas.document; + expect(reducerDocument.layers[0]).toMatchObject({ + source: { bitmap: durableImage, type: 'paint' }, + }); + expect(engine.document.getDocument()).toBe(reducerDocument); + expect(engine.stores.canUndo.get()).toBe(true); + const committedExport = await engine.exports.exportLayerPixels(source.id); + expect(committedExport.status).toBe('ok'); + if (committedExport.status === 'ok') { + expect(drawGraphContains(committedExport.surface as StubRasterSurface, backend, 'bitmap-filtered-result')).toBe( + true + ); + } + + const screen = createFakeCanvas(); + engine.surface.attach(screen.element, createFakeCanvas().element); + raf.flush(); + expect(drawGraphContains(screen.surface, backend, 'bitmap-filtered-result')).toBe(true); + engine.lifecycle.dispose(); + }); + + it('finishes copy undo and restores the exact prior selection after an applied observer fault', async () => { + const source = filterLayer('source'); + const selected = filterLayer('selected'); + const beforeDocument = { ...filterDoc([source, selected]), selectedLayerId: selected.id }; + const { projectId, store } = createReducerBackedStore(beforeDocument); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode: 'copy', + rect: exported.rect, + }); + if (result.status !== 'committed') { + throw new Error('expected a committed copy'); + } + const copy = structuredClone(engine.document.getDocument()!.layers[0]!); + const unsubscribeFault = store.subscribe(() => { + throw new Error('copy undo observer failed'); + }); + + expect(() => engine.history.undo()).not.toThrow(); + + expect(engine.document.getDocument()).toEqual(beforeDocument); + expect(engine.document.getDocument()!.selectedLayerId).toBe(selected.id); + expect((await engine.exports.exportLayerPixels(result.layerId)).status).toBe('missing'); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + unsubscribeFault(); + + engine.history.redo(); + expect(engine.document.getDocument()!.layers[0]).toEqual(copy); + expect(engine.document.getDocument()!.selectedLayerId).toBe(result.layerId); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('keeps copy undo retryable when removal fails after restoring the prior selection', async () => { + const source = filterLayer('source'); + const selected = filterLayer('selected'); + const beforeDocument = { ...filterDoc([source, selected]), selectedLayerId: selected.id }; + const { dispatch, projectId, store } = createReducerBackedStore(beforeDocument); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode: 'copy', + rect: exported.rect, + }); + if (result.status !== 'committed') { + throw new Error('expected a committed copy'); + } + const reducerDispatch = dispatch.getMockImplementation(); + if (!reducerDispatch) { + throw new Error('expected reducer-backed dispatch'); + } + let failRemoval = true; + dispatch.mockImplementation((action: EngineTestAction) => { + if (failRemoval && action.type === 'removeCanvasLayers') { + failRemoval = false; + throw new Error('copy removal failed'); + } + reducerDispatch(action); + }); + + expect(() => engine.history.undo()).toThrow('copy removal failed'); + + expect(engine.document.getDocument()!.layers.some((layer) => layer.id === result.layerId)).toBe(true); + expect(engine.document.getDocument()!.selectedLayerId).toBe(selected.id); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.history.undo(); + expect(engine.document.getDocument()).toEqual(beforeDocument); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + engine.lifecycle.dispose(); + }); + + it.each(['replace', 'copy'] as const)('commits %s when a history-state observer throws', async (mode) => { + const source = filterLayer('source'); + const { projectId, store } = createReducerBackedStore(filterDoc([source])); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected a filterable export'); + } + const unsubscribeFault = engine.stores.canUndo.subscribe(() => { + throw new Error('history observer failed'); + }); + + const result = await engine.layers.commitRasterFilterResult({ + guard: exported.guard, + image: durableImage, + mode, + rect: exported.rect, + }); + + expect(result).toMatchObject({ status: 'committed' }); + expect(engine.stores.canUndo.get()).toBe(true); + if (result.status === 'committed') { + expect((await engine.exports.exportLayerPixels(result.layerId)).status).toBe('ok'); + } + unsubscribeFault(); + engine.lifecycle.dispose(); + }); +}); + +describe('commitGeneratedImageResult', () => { + const generatedImage = { height: 18, imageName: 'workflow-result.png', width: 24 }; + const generatedOrigin = { x: -9, y: 14 }; + + const workflowRaster = (id = 'source'): CanvasRasterLayerContractV2 => ({ + adjustments: { brightness: 0.2, contrast: -0.1, saturation: 0.3 }, + blendMode: 'multiply', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 0.65, + source: { fill: '#fff', height: 10, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 10 }, + transform: { rotation: 0.4, scaleX: 2, scaleY: 3, x: 5, y: 6 }, + type: 'raster', + }); + + const workflowControl = (id = 'source'): CanvasLayerContract => ({ + adapter: { + beginEndStepPct: [0.15, 0.85], + controlMode: 'more_control', + kind: 'controlnet', + model: 'control-model', + weight: 0.7, + }, + blendMode: 'screen', + filter: { settings: { low: 12, high: 90 }, type: 'canny' }, + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 0.55, + source: { fill: '#fff', height: 10, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 10 }, + transform: { rotation: -0.3, scaleX: 1.5, scaleY: 0.75, x: 11, y: -4 }, + type: 'control', + withTransparencyEffect: true, + }); + + const workflowDocument = (layers: CanvasLayerContract[] = [workflowRaster()]): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId: layers.at(-1)?.id ?? null, + version: 2, + width: 100, + }); + + const createGeneratedFaultBackend = () => { + const base = createTestStubRasterBackend(); + let allocationCountdown: number | null = null; + let drawCountdown: number | null = null; + let createHook: { countdown: number; run: () => void } | null = null; + const backend: StubRasterBackend = { + ...base, + createSurface: (width, height) => { + if (allocationCountdown !== null) { + if (allocationCountdown === 0) { + allocationCountdown = null; + throw new Error('generated cache allocation failed'); + } + allocationCountdown -= 1; + } + if (createHook) { + if (createHook.countdown === 0) { + const hook = createHook; + createHook = null; + hook.run(); + } else { + createHook.countdown -= 1; + } + } + const surface = base.createSurface(width, height); + const ctx = new Proxy(surface.ctx, { + get: (target, property, receiver) => { + const value = Reflect.get(target, property, receiver); + if (property !== 'drawImage' || typeof value !== 'function') { + return value; + } + return (...args: unknown[]) => { + if (drawCountdown !== null) { + if (drawCountdown === 0) { + drawCountdown = null; + throw new Error('generated cache draw failed'); + } + drawCountdown -= 1; + } + return Reflect.apply(value, target, args); + }; + }, + }); + Object.defineProperty(surface, 'ctx', { value: ctx }); + return surface; + }, + }; + return { + armAllocation: (successfulCreates: number) => { + allocationCountdown = successfulCreates; + }, + armCreateHook: (successfulCreates: number, run: () => void) => { + createHook = { countdown: successfulCreates, run }; + }, + armDraw: (successfulDraws: number) => { + drawCountdown = successfulDraws; + }, + backend, + }; + }; + + it.each([ + { base: 'z-image', expectedKind: 'z_image_control' }, + { base: 'sd-1', expectedKind: 'controlnet' }, + { base: 'flux', expectedKind: 'controlnet' }, + ] as const)( + 'uses $expectedKind defaults when workflow Copy To Control runs under $base', + async ({ base, expectedKind }) => { + const source = workflowRaster(); + const { projectId, store } = createReducerBackedStore(workflowDocument([source]), base); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + + const result = await engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + target: 'copy-control', + }); + + expect(result.status).toBe('committed'); + if (result.status !== 'committed') { + throw new Error('expected a committed control copy'); + } + const created = engine.document.getDocument()!.layers.find((layer) => layer.id === result.layerId); + expect(created?.type === 'control' ? created.adapter.kind : null).toBe(expectedKind); + engine.lifecycle.dispose(); + } + ); + + it('replaces a raster at the generated origin and native size, clears baked adjustments, and replays exactly', async () => { + const source = workflowRaster(); + const before = structuredClone(source); + const backend = createRecordingRasterBackend(); + backend.createImageBitmap = vi.fn(() => Promise.resolve(recordingBitmap('workflow-result'))); + const bitmapStore = createSpyBitmapStore(); + const { projectId, store } = createReducerBackedStore(workflowDocument([source])); + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + + const result = await engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + target: 'replace', + }); + + expect(result).toEqual({ layerId: source.id, status: 'committed' }); + const after = engine.document.getDocument()!.layers[0]!; + expect(after).toEqual({ + blendMode: source.blendMode, + id: source.id, + isEnabled: source.isEnabled, + isLocked: source.isLocked, + name: source.name, + opacity: source.opacity, + source: { bitmap: generatedImage, offset: generatedOrigin, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }); + expect('adjustments' in after).toBe(false); + const committedPixels = await engine.exports.exportLayerPixels(source.id); + expect(committedPixels.status).toBe('ok'); + if (committedPixels.status === 'ok') { + expect(committedPixels.rect).toEqual({ ...generatedOrigin, height: 18, width: 24 }); + expect(drawGraphContains(committedPixels.surface as StubRasterSurface, backend, 'bitmap-workflow-result')).toBe( + true + ); + } + expect(engine.stores.canUndo.get()).toBe(true); + + engine.history.undo(); + expect(engine.document.getDocument()!.layers[0]).toEqual(before); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + + engine.history.redo(); + expect(engine.document.getDocument()!.layers[0]).toEqual(after); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + expect(bitmapStore.discardLayer).toHaveBeenCalledTimes(2); + engine.lifecycle.dispose(); + }); + + it('replaces a control while preserving adapter, filter, transparency effect, and layer presentation', async () => { + const source = workflowControl(); + const { projectId, store } = createReducerBackedStore(workflowDocument([source])); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + + await expect( + engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + target: 'replace', + }) + ).resolves.toEqual({ layerId: source.id, status: 'committed' }); + + expect(engine.document.getDocument()!.layers[0]).toEqual({ + ...source, + source: { bitmap: generatedImage, offset: generatedOrigin, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + }); + engine.history.undo(); + expect(engine.document.getDocument()!.layers[0]).toEqual(source); + engine.history.redo(); + expect(engine.document.getDocument()!.layers[0]).toMatchObject({ + adapter: source.type === 'control' ? source.adapter : undefined, + filter: source.type === 'control' ? source.filter : undefined, + withTransparencyEffect: true, + }); + engine.lifecycle.dispose(); + }); + + it('copies a generated result to an unlocked raster immediately above its source and restores selection on undo', async () => { + const above = workflowRaster('above'); + const source = workflowControl('source'); + const selected = workflowRaster('selected'); + const document = { ...workflowDocument([above, source, selected]), selectedLayerId: selected.id }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + + const result = await engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + target: 'copy-raster', + }); + if (result.status !== 'committed') { + throw new Error(`expected a committed copy, received ${JSON.stringify(result)}`); + } + + expect(engine.document.getDocument()!.layers.map((layer) => layer.id)).toEqual([ + above.id, + result.layerId, + source.id, + selected.id, + ]); + expect(engine.document.getDocument()!.layers[2]).toEqual(source); + expect(engine.document.getDocument()!.layers[1]).toMatchObject({ + id: result.layerId, + isEnabled: true, + isLocked: false, + opacity: 1, + source: { bitmap: generatedImage, offset: generatedOrigin, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }); + expect(engine.document.getDocument()!.selectedLayerId).toBe(result.layerId); + + engine.history.undo(); + expect(engine.document.getDocument()).toEqual(document); + expect(engine.stores.canUndo.get()).toBe(false); + engine.history.redo(); + expect(engine.document.getDocument()!.layers[1]?.id).toBe(result.layerId); + expect(engine.document.getDocument()!.selectedLayerId).toBe(result.layerId); + expect(engine.stores.canRedo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + const createPendingGuardHarness = async () => { + const source = workflowRaster(); + const document = workflowDocument([source]); + const decoded = createDeferred(); + const reactive = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: (imageName) => + imageName === generatedImage.imageName ? decoded.promise : Promise.resolve(new Blob()), + projectId: 'p1', + store: reactive.store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + (reactive.store.dispatch as Mock).mockClear(); + const pending = engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + target: 'replace', + }); + return { ...reactive, decoded, document, engine, exported, pending, source }; + }; + + it('returns failed without mutation when the durable generated image cannot be resolved', async () => { + const source = workflowRaster(); + const document = workflowDocument([source]); + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: (imageName) => + imageName === generatedImage.imageName + ? Promise.reject(new Error('generated decode failed')) + : Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + (store.dispatch as Mock).mockClear(); + + await expect( + engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + target: 'replace', + }) + ).resolves.toEqual({ message: 'generated decode failed', status: 'failed' }); + expect(engine.document.getDocument()).toEqual(document); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('returns missing after decode when the source was deleted', async () => { + const harness = await createPendingGuardHarness(); + harness.setDocument({ ...harness.document, layers: [] }); + harness.decoded.resolve(new Blob()); + await expect(harness.pending).resolves.toEqual({ status: 'missing' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + harness.engine.lifecycle.dispose(); + }); + + it('returns locked after decode when the source became locked', async () => { + const harness = await createPendingGuardHarness(); + harness.setDocument({ ...harness.document, layers: [{ ...harness.source, isLocked: true }] }); + harness.decoded.resolve(new Blob()); + await expect(harness.pending).resolves.toEqual({ status: 'locked' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + harness.engine.lifecycle.dispose(); + }); + + it('returns unsupported after decode when the source became a mask', async () => { + const harness = await createPendingGuardHarness(); + harness.setDocument({ ...harness.document, layers: [maskLayer(harness.source.id)] }); + harness.decoded.resolve(new Blob()); + await expect(harness.pending).resolves.toEqual({ status: 'unsupported' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + harness.engine.lifecycle.dispose(); + }); + + it('returns stale after decode when the immutable source contract changed', async () => { + const harness = await createPendingGuardHarness(); + harness.setDocument({ ...harness.document, layers: [{ ...harness.source, opacity: 0.1 }] }); + harness.decoded.resolve(new Blob()); + await expect(harness.pending).resolves.toEqual({ status: 'stale' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + harness.engine.lifecycle.dispose(); + }); + + it('returns stale after decode when the document was replaced with the same source object', async () => { + const harness = await createPendingGuardHarness(); + harness.setDocument({ ...harness.document, width: 200 }, 1); + harness.decoded.resolve(new Blob()); + await expect(harness.pending).resolves.toEqual({ status: 'stale' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + harness.engine.lifecycle.dispose(); + }); + + it('commits after decode when another project became active', async () => { + const harness = await createPendingGuardHarness(); + harness.setActiveProjectId('p2'); + harness.decoded.resolve(new Blob()); + await expect(harness.pending).resolves.toEqual({ layerId: 'source', status: 'committed' }); + expect(harness.store.dispatch).toHaveBeenCalled(); + harness.engine.lifecycle.dispose(); + }); + + it('returns busy after decode while a pointer gesture is open', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const harness = await createPendingGuardHarness(); + const overlay = createInputCanvas(); + harness.engine.surface.attach(createInputCanvas().element, overlay.element); + overlay.fire('pointerdown', pointerAt(5, 5)); + harness.decoded.resolve(new Blob()); + await expect(harness.pending).resolves.toEqual({ status: 'busy' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + harness.engine.lifecycle.dispose(); + }); + + it('does not publish when an operation starts during generated-image decode', async () => { + const harness = await createPendingGuardHarness(); + const cleanupPreview = vi.fn(); + getCanvasOperations(harness.engine).controller.start({ + cleanupPreview, + guard: harness.exported.guard, + identity: { kind: 'filter', layerId: harness.source.id, projectId: 'p1' }, + }); + + harness.decoded.resolve(new Blob()); + + await expect(harness.pending).resolves.toEqual({ status: 'busy' }); + expect(harness.engine.document.getDocument()).toEqual(harness.document); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + expect(cleanupPreview).not.toHaveBeenCalled(); + expect(getCanvasOperations(harness.engine).controller.getSnapshot()).toMatchObject({ + identity: { kind: 'filter' }, + status: 'active', + }); + harness.engine.lifecycle.dispose(); + }); + + it('does not publish when an operation starts and ends during generated-image decode', async () => { + const harness = await createPendingGuardHarness(); + const operation = getCanvasOperations(harness.engine).controller.start({ + cleanupPreview: vi.fn(), + guard: harness.exported.guard, + identity: { kind: 'filter', layerId: harness.source.id, projectId: 'p1' }, + }); + operation?.cancel(); + + harness.decoded.resolve(new Blob()); + + await expect(harness.pending).resolves.toEqual({ status: 'busy' }); + expect(harness.engine.document.getDocument()).toEqual(harness.document); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + expect(getCanvasOperations(harness.engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + harness.engine.lifecycle.dispose(); + }); + + it('returns aborted without resolving the generated image when already cancelled', async () => { + const source = workflowRaster(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const { projectId, store } = createReducerBackedStore(workflowDocument([source])); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: resolver, + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + resolver.mockClear(); + const controller = new AbortController(); + controller.abort(); + + await expect( + engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + signal: controller.signal, + target: 'replace', + }) + ).resolves.toEqual({ status: 'aborted' }); + expect(resolver).not.toHaveBeenCalled(); + expect(engine.document.getDocument()).toEqual(workflowDocument([source])); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('returns aborted during bitmap decode, releases the late bitmap, and never mutates', async () => { + const source = workflowRaster(); + const bitmapDeferred = createDeferred(); + const bitmap = recordingBitmap('late-generated'); + const base = createTestStubRasterBackend(); + const createImageBitmap = vi.fn(() => bitmapDeferred.promise); + const { projectId, store } = createReducerBackedStore(workflowDocument([source])); + const engine = createCanvasEngine({ + backend: { ...base, createImageBitmap }, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + const controller = new AbortController(); + const pending = engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + signal: controller.signal, + target: 'replace', + }); + await vi.waitFor(() => expect(createImageBitmap).toHaveBeenCalledOnce()); + + controller.abort(); + bitmapDeferred.resolve(bitmap); + + await expect(pending).resolves.toEqual({ status: 'aborted' }); + expect(bitmap.close).toHaveBeenCalledOnce(); + expect(engine.document.getDocument()).toEqual(workflowDocument([source])); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('returns stale when unpersisted paint pixels change while the generated image resolves', async () => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const source: CanvasRasterLayerContractV2 = { + ...workflowRaster(), + source: { + bitmap: { height: 10, imageName: 'workflow-paint-source.png', width: 10 }, + type: 'paint', + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + }; + const generatedBlob = createDeferred(); + const { projectId, store } = createReducerBackedStore(workflowDocument([source])); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: (imageName) => + imageName === generatedImage.imageName ? generatedBlob.promise : Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + const pending = engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + target: 'replace', + }); + + engine.selection.selectAll(); + engine.selection.fillSelection(); + const afterPaintEdit = structuredClone(engine.document.getDocument()!); + generatedBlob.resolve(new Blob()); + + await expect(pending).resolves.toEqual({ status: 'stale' }); + expect(engine.document.getDocument()).toEqual(afterPaintEdit); + expect(engine.document.getDocument()!.layers[0]).not.toMatchObject({ source: { bitmap: generatedImage } }); + expect(engine.stores.canUndo.get()).toBe(true); + engine.lifecycle.dispose(); + }); + + it.each([ + { successfulCreates: 0, target: 'replace' as const }, + { successfulCreates: 2, target: 'replace' as const }, + { successfulCreates: 1, target: 'copy-raster' as const }, + ])( + 'leaves document, cache, selection, and history exact when $target allocation fails after $successfulCreates successful creates', + async ({ successfulCreates, target }) => { + const source = workflowRaster(); + const selected = workflowRaster('selected'); + const document = { ...workflowDocument([source, selected]), selectedLayerId: selected.id }; + const faults = createGeneratedFaultBackend(); + const bitmap = recordingBitmap('allocation-failure'); + faults.backend.createImageBitmap = vi.fn(() => Promise.resolve(bitmap)); + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: faults.backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + const cache = exported.surface; + const cacheCalls = structuredClone((cache as StubRasterSurface).callLog); + (store.dispatch as Mock).mockClear(); + faults.armAllocation(successfulCreates); + + const result = await engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + target, + }); + + expect(result).toEqual({ message: 'generated cache allocation failed', status: 'failed' }); + expect(bitmap.close).toHaveBeenCalledOnce(); + expect(engine.document.getDocument()).toEqual(document); + expect(engine.document.getDocument()!.selectedLayerId).toBe(selected.id); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(false); + const after = await engine.exports.exportLayerPixels(source.id); + expect(after.status).toBe('ok'); + if (after.status === 'ok') { + expect(after.surface).toBe(cache); + expect(after.guard.cacheVersion).toBe(exported.guard.cacheVersion); + expect((after.surface as StubRasterSurface).callLog).toEqual(cacheCalls); + } + engine.lifecycle.dispose(); + } + ); + + it.each([ + { successfulDraws: 0, target: 'replace' as const }, + { successfulDraws: 2, target: 'replace' as const }, + { successfulDraws: 1, target: 'copy-raster' as const }, + ])( + 'leaves document, cache, selection, and history exact when $target draw fails after $successfulDraws successful draws', + async ({ successfulDraws, target }) => { + const source = workflowRaster(); + const selected = workflowRaster('selected'); + const document = { ...workflowDocument([source, selected]), selectedLayerId: selected.id }; + const faults = createGeneratedFaultBackend(); + const bitmap = recordingBitmap('draw-failure'); + faults.backend.createImageBitmap = vi.fn(() => Promise.resolve(bitmap)); + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: faults.backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + const cache = exported.surface; + const cacheCalls = structuredClone((cache as StubRasterSurface).callLog); + (store.dispatch as Mock).mockClear(); + faults.armDraw(successfulDraws); + + const result = await engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + target, + }); + + expect(result).toEqual({ message: 'generated cache draw failed', status: 'failed' }); + expect(bitmap.close).toHaveBeenCalledOnce(); + expect(engine.document.getDocument()).toEqual(document); + expect(engine.document.getDocument()!.selectedLayerId).toBe(selected.id); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(false); + const after = await engine.exports.exportLayerPixels(source.id); + expect(after.status).toBe('ok'); + if (after.status === 'ok') { + expect(after.surface).toBe(cache); + expect(after.guard.cacheVersion).toBe(exported.guard.cacheVersion); + expect((after.surface as StubRasterSurface).callLog).toEqual(cacheCalls); + } + engine.lifecycle.dispose(); + } + ); + + it.each([ + { successfulCreates: 2, target: 'replace' as const }, + { successfulCreates: 1, target: 'copy-raster' as const }, + ])( + 'returns aborted without mutation when $target is cancelled during cache preparation', + async ({ successfulCreates, target }) => { + const source = workflowRaster(); + const document = workflowDocument([source]); + const faults = createGeneratedFaultBackend(); + const controller = new AbortController(); + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: faults.backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + (store.dispatch as Mock).mockClear(); + faults.armCreateHook(successfulCreates, () => controller.abort()); + + await expect( + engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + signal: controller.signal, + target, + }) + ).resolves.toEqual({ status: 'aborted' }); + expect(engine.document.getDocument()).toEqual(document); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + } + ); + + it.each([ + { successfulCreates: 2, target: 'replace' as const }, + { successfulCreates: 1, target: 'copy-raster' as const }, + ])('revalidates the exact guard immediately before $target publication', async ({ successfulCreates, target }) => { + const source = workflowRaster(); + const document = workflowDocument([source]); + const reactive = createReactiveStore(document); + const faults = createGeneratedFaultBackend(); + const engine = createCanvasEngine({ + backend: faults.backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store: reactive.store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + (reactive.store.dispatch as Mock).mockClear(); + faults.armCreateHook(successfulCreates, () => { + reactive.setDocument({ ...document, layers: [{ ...source, opacity: 0.2 }] }); + }); + + await expect( + engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + target, + }) + ).resolves.toEqual({ status: 'stale' }); + expect(reactive.store.dispatch).not.toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('keeps replace undo and redo retryable when detached cache preparation fails', async () => { + const source = workflowRaster(); + const document = workflowDocument([source]); + const faults = createGeneratedFaultBackend(); + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: faults.backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + await expect( + engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + target: 'replace', + }) + ).resolves.toEqual({ layerId: source.id, status: 'committed' }); + const committed = structuredClone(engine.document.getDocument()!); + + faults.armAllocation(0); + expect(() => engine.history.undo()).toThrow('generated cache allocation failed'); + expect(engine.document.getDocument()).toEqual(committed); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.history.undo(); + expect(engine.document.getDocument()).toEqual(document); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + + faults.armDraw(0); + expect(() => engine.history.redo()).toThrow('generated cache draw failed'); + expect(engine.document.getDocument()).toEqual(document); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + + engine.history.redo(); + expect(engine.document.getDocument()).toEqual(committed); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('keeps copy redo retryable, absent, and selection-exact when detached cache preparation fails', async () => { + const source = workflowRaster(); + const selected = workflowRaster('selected'); + const document = { ...workflowDocument([source, selected]), selectedLayerId: selected.id }; + const faults = createGeneratedFaultBackend(); + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: faults.backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + const result = await engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + target: 'copy-raster', + }); + if (result.status !== 'committed') { + throw new Error('expected a committed workflow copy'); + } + const committed = structuredClone(engine.document.getDocument()!); + + engine.history.undo(); + expect(engine.document.getDocument()).toEqual(document); + faults.armAllocation(0); + expect(() => engine.history.redo()).toThrow('generated cache allocation failed'); + expect(engine.document.getDocument()).toEqual(document); + expect(engine.document.getDocument()!.selectedLayerId).toBe(selected.id); + expect(engine.document.getDocument()!.layers.some((layer) => layer.id === result.layerId)).toBe(false); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + + engine.history.redo(); + expect(engine.document.getDocument()).toEqual(committed); + expect(engine.document.getDocument()!.selectedLayerId).toBe(result.layerId); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('reloads a replaced generated image from its durable bitmap contract', async () => { + const source = workflowRaster(); + const { projectId, store } = createReducerBackedStore(workflowDocument([source])); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels(source.id); + if (exported.status !== 'ok') { + throw new Error('expected an exportable workflow source'); + } + await expect( + engine.layers.commitGeneratedImageResult({ + guard: exported.guard, + image: generatedImage, + origin: generatedOrigin, + target: 'replace', + }) + ).resolves.toEqual({ layerId: source.id, status: 'committed' }); + const reloadedDocument = structuredClone(engine.document.getDocument()!); + engine.lifecycle.dispose(); + + const reloadedStore = createReducerBackedStore(reloadedDocument); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const reloadedEngine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: resolver, + projectId: reloadedStore.projectId, + store: reloadedStore.store, + }); + expect((await reloadedEngine.exports.exportLayerPixels(source.id)).status).toBe('ok'); + expect(resolver).toHaveBeenCalledWith(generatedImage.imageName, expect.any(AbortSignal)); + reloadedEngine.lifecycle.dispose(); + }); +}); + +describe('replaceSelectionFromImage', () => { + const resultImage = { height: 10, imageName: 'sam-result.png', width: 10 }; + const resultRect = { height: 10, width: 10, x: -7, y: 13 }; + + const sourceLayer = (): CanvasRasterLayerContractV2 => ({ + blendMode: 'normal', + id: 'sam-source', + isEnabled: true, + isLocked: false, + name: 'SAM source', + opacity: 1, + source: { fill: '#fff', height: 10, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 10 }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: -7, y: 13 }, + type: 'raster', + }); + + const sourceDocument = (layer: CanvasLayerContract = sourceLayer()): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [layer], + selectedLayerId: layer.id, + version: 2, + width: 100, + }); + + const alphaBackend = (alpha: number): StubRasterBackend => { + const base = createTestStubRasterBackend(); + return { + ...base, + createSurface: (width, height) => { + const surface = base.createSurface(width, height); + Object.defineProperty(surface.ctx, 'getImageData', { + value: (_x: number, _y: number, readWidth: number, readHeight: number) => { + const data = new Uint8ClampedArray(readWidth * readHeight * 4); + for (let index = 3; index < data.length; index += 4) { + data[index] = alpha; + } + return { colorSpace: 'srgb', data, height: readHeight, width: readWidth } as ImageData; + }, + }); + return surface; + }, + }; + }; + + const createHarness = async ({ + alpha = 0, + backend = alphaBackend(alpha), + imageResolver = () => Promise.resolve(new Blob()), + seedSelection = true, + }: { + alpha?: number; + backend?: StubRasterBackend; + imageResolver?: (imageName: string, signal?: AbortSignal) => Promise; + seedSelection?: boolean; + } = {}) => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const layer = sourceLayer(); + const document = sourceDocument(layer); + const reactive = createReactiveStore(document); + const engine = createCanvasEngine({ backend, imageResolver, projectId: 'p1', store: reactive.store }); + const exported = await engine.exports.exportLayerPixels(layer.id); + if (exported.status !== 'ok') { + throw new Error('expected an export guard'); + } + if (seedSelection) { + engine.selection.selectAll(); + } + (reactive.store.dispatch as Mock).mockClear(); + return { ...reactive, document, engine, guard: exported.guard, layer }; + }; + + const createPendingHarness = async () => { + const decoded = createDeferred(); + const harness = await createHarness({ imageResolver: () => decoded.promise }); + const pending = harness.engine.selection.replaceSelectionFromImage(harness.guard, resultImage, resultRect); + return { ...harness, decoded, pending }; + }; + + it('decodes and places a non-empty alpha result into the transient selection', async () => { + const imageResolver = vi.fn((_imageName: string, _signal?: AbortSignal) => Promise.resolve(new Blob())); + const harness = await createHarness({ alpha: 255, imageResolver, seedSelection: false }); + + await expect( + harness.engine.selection.replaceSelectionFromImage(harness.guard, resultImage, resultRect) + ).resolves.toEqual({ + status: 'selected', + }); + expect(imageResolver).toHaveBeenCalledWith(resultImage.imageName, undefined); + expect(harness.engine.stores.hasSelection.get()).toBe(true); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + harness.engine.lifecycle.dispose(); + }); + + it('reports selected when a has-selection observer throws after the replacement is applied', async () => { + const harness = await createHarness({ alpha: 255, seedSelection: false }); + const unsubscribeFault = harness.engine.stores.hasSelection.subscribe(() => { + throw new Error('selection state observer failed'); + }); + + await expect( + harness.engine.selection.replaceSelectionFromImage(harness.guard, resultImage, resultRect) + ).resolves.toEqual({ + status: 'selected', + }); + + expect(harness.engine.stores.hasSelection.get()).toBe(true); + unsubscribeFault(); + harness.engine.lifecycle.dispose(); + }); + + it('reports selected and clears derived selection state for an empty mask without clearing the prior surface', async () => { + const base = alphaBackend(0); + const surfaces: StubRasterSurface[] = []; + const backend: StubRasterBackend = { + ...base, + createSurface: (width, height) => { + const surface = base.createSurface(width, height); + surfaces.push(surface); + return surface; + }, + }; + const harness = await createHarness({ backend }); + const priorMask = surfaces.at(-1); + if (!priorMask) { + throw new Error('expected a seeded selection surface'); + } + const priorMaskClear = vi.fn(() => { + throw new Error('prior selection surface must not be cleared'); + }); + Object.defineProperty(priorMask.ctx, 'clearRect', { value: priorMaskClear }); + + await expect( + harness.engine.selection.replaceSelectionFromImage(harness.guard, resultImage, resultRect) + ).resolves.toEqual({ + status: 'selected', + }); + + expect(priorMaskClear).not.toHaveBeenCalled(); + expect(harness.engine.stores.hasSelection.get()).toBe(false); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + harness.engine.lifecycle.dispose(); + }); + + it('closes the decoded bitmap and preserves selection when replacement-surface allocation fails', async () => { + const base = alphaBackend(255); + const close = vi.fn(); + let failAllocation = false; + const backend: StubRasterBackend = { + ...base, + createImageBitmap: vi.fn(() => + Promise.resolve({ close, height: resultImage.height, width: resultImage.width } as unknown as ImageBitmap) + ), + createSurface: (width, height) => { + if (failAllocation) { + throw new Error('selection surface allocation failed'); + } + return base.createSurface(width, height); + }, + }; + const harness = await createHarness({ backend }); + failAllocation = true; + + await expect( + harness.engine.selection.replaceSelectionFromImage(harness.guard, resultImage, resultRect) + ).resolves.toEqual({ + message: 'selection surface allocation failed', + status: 'failed', + }); + + expect(close).toHaveBeenCalledOnce(); + expect(harness.engine.stores.hasSelection.get()).toBe(true); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + harness.engine.lifecycle.dispose(); + }); + + it('returns aborted before decode without touching the existing selection', async () => { + const imageResolver = vi.fn(() => Promise.resolve(new Blob())); + const harness = await createHarness({ imageResolver }); + const controller = new AbortController(); + controller.abort(); + + await expect( + harness.engine.selection.replaceSelectionFromImage(harness.guard, resultImage, resultRect, controller.signal) + ).resolves.toEqual({ status: 'aborted' }); + expect(imageResolver).not.toHaveBeenCalled(); + expect(harness.engine.stores.hasSelection.get()).toBe(true); + harness.engine.lifecycle.dispose(); + }); + + it('returns aborted when cancellation lands during image resolution', async () => { + const decoded = createDeferred(); + const harness = await createHarness({ imageResolver: () => decoded.promise }); + const controller = new AbortController(); + + const pending = harness.engine.selection.replaceSelectionFromImage( + harness.guard, + resultImage, + resultRect, + controller.signal + ); + controller.abort(); + decoded.resolve(new Blob()); + + await expect(pending).resolves.toEqual({ status: 'aborted' }); + expect(harness.engine.stores.hasSelection.get()).toBe(true); + harness.engine.lifecycle.dispose(); + }); + + it('returns aborted and closes the bitmap when cancellation lands during bitmap decode', async () => { + const bitmap = createDeferred(); + const close = vi.fn(); + const backend = { ...alphaBackend(0), createImageBitmap: vi.fn(() => bitmap.promise) }; + const harness = await createHarness({ backend }); + const controller = new AbortController(); + + const pending = harness.engine.selection.replaceSelectionFromImage( + harness.guard, + resultImage, + resultRect, + controller.signal + ); + await vi.waitFor(() => expect(backend.createImageBitmap).toHaveBeenCalledOnce()); + controller.abort(); + bitmap.resolve({ close, height: 10, width: 10 } as unknown as ImageBitmap); + + await expect(pending).resolves.toEqual({ status: 'aborted' }); + expect(close).toHaveBeenCalledOnce(); + expect(harness.engine.stores.hasSelection.get()).toBe(true); + harness.engine.lifecycle.dispose(); + }); + + it('returns failed on decode failure without touching the existing selection', async () => { + const harness = await createHarness({ imageResolver: () => Promise.reject(new Error('SAM decode failed')) }); + + await expect( + harness.engine.selection.replaceSelectionFromImage(harness.guard, resultImage, resultRect) + ).resolves.toEqual({ + message: 'SAM decode failed', + status: 'failed', + }); + expect(harness.engine.stores.hasSelection.get()).toBe(true); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + harness.engine.lifecycle.dispose(); + }); + + it.each([ + { + expected: 'missing', + label: 'source deletion', + mutate: (harness: Awaited>) => + harness.setDocument({ ...harness.document, layers: [] }), + }, + { + expected: 'stale', + label: 'source contract edit', + mutate: (harness: Awaited>) => + harness.setDocument({ ...harness.document, layers: [{ ...harness.layer, opacity: 0.5 }] }), + }, + { + expected: 'locked', + label: 'source lock', + mutate: (harness: Awaited>) => + harness.setDocument({ ...harness.document, layers: [{ ...harness.layer, isLocked: true }] }), + }, + { + expected: 'unsupported', + label: 'source type change', + mutate: (harness: Awaited>) => { + const { source: _source, ...base } = harness.layer; + const mask: CanvasInpaintMaskLayerContract = { + ...base, + mask: { bitmap: null, fill: { color: '#fff', style: 'solid' } }, + type: 'inpaint_mask', + }; + harness.setDocument({ ...harness.document, layers: [mask] }); + }, + }, + { + expected: 'stale', + label: 'document replacement reusing the layer', + mutate: (harness: Awaited>) => harness.setDocument({ ...harness.document }, 1), + }, + ] as const)('refuses $label without mutating the selection', async ({ expected, mutate }) => { + const harness = await createPendingHarness(); + mutate(harness); + const selectionAfterExternalChange = harness.engine.stores.hasSelection.get(); + harness.decoded.resolve(new Blob()); + + await expect(harness.pending).resolves.toEqual({ status: expected }); + expect(harness.engine.stores.hasSelection.get()).toBe(selectionAfterExternalChange); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + harness.engine.lifecycle.dispose(); + }); + + it('returns busy for an open pointer gesture without mutating the selection', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const harness = await createPendingHarness(); + const screen = createInputCanvas(); + const overlay = createInputCanvas(); + harness.engine.surface.attach(screen.element, overlay.element); + raf.flush(); + overlay.fire('pointerdown', pointerAt(5, 5)); + harness.decoded.resolve(new Blob()); + + await expect(harness.pending).resolves.toEqual({ status: 'busy' }); + expect(harness.engine.stores.hasSelection.get()).toBe(true); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + harness.engine.lifecycle.dispose(); + }); + + it('returns stale after an unpersisted paint-cache edit without mutating the selection', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + const decoded = createDeferred(); + const document = paintDoc(); + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: alphaBackend(0), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => decoded.promise, + projectId, + store, + }); + const screen = createInputCanvas(); + const overlay = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + engine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(5, 5)); + overlay.fire('pointermove', pointerAt(8, 8)); + overlay.fire('pointerup', pointerAt(8, 8, { buttons: 0 })); + const exported = await engine.exports.exportLayerPixels('paint1'); + if (exported.status !== 'ok') { + throw new Error('expected live paint pixels'); + } + engine.selection.selectAll(); + const pending = engine.selection.replaceSelectionFromImage(exported.guard, resultImage, resultRect); + + overlay.fire('pointerdown', pointerAt(15, 15)); + overlay.fire('pointermove', pointerAt(18, 18)); + overlay.fire('pointerup', pointerAt(18, 18, { buttons: 0 })); + decoded.resolve(new Blob()); + + await expect(pending).resolves.toEqual({ status: 'stale' }); + expect(engine.stores.hasSelection.get()).toBe(true); + engine.lifecycle.dispose(); + }); +}); + +describe('commitMaskImageResult', () => { + const resultImage = { height: 12, imageName: 'durable-sam-result.png', width: 16 }; + const resultRect = { height: 12, width: 16, x: -5, y: 7 }; + + const source = (): CanvasRasterLayerContractV2 => ({ + blendMode: 'normal', + id: 'source', + isEnabled: true, + isLocked: false, + name: 'Source', + opacity: 1, + source: { fill: '#fff', height: 12, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 16 }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: -5, y: 7 }, + type: 'raster', + }); + + const existingInpaint = (): CanvasInpaintMaskLayerContract => ({ + blendMode: 'normal', + id: 'existing-mask', + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + name: 'Inpaint Mask 1', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', + }); + + const existingRegional = (): CanvasLayerContract => ({ + autoNegative: false, + blendMode: 'normal', + id: 'existing-region', + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#83d683', style: 'solid' } }, + name: 'Regional Guidance 1', + negativePrompt: null, + opacity: 0.5, + positivePrompt: null, + referenceImages: [], + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'regional_guidance', + }); + + const docFor = (target: 'inpaint_mask' | 'regional_guidance'): CanvasDocumentContractV2 => { + const layer = source(); + const below = { ...source(), id: 'below', name: 'Below' }; + return { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [target === 'inpaint_mask' ? existingInpaint() : existingRegional(), layer, below], + selectedLayerId: below.id, + version: 2, + width: 100, + }; + }; + + it.each([ + { + expected: { + blendMode: 'normal', + isEnabled: true, + isLocked: false, + mask: { + bitmap: resultImage, + fill: { color: '#e07575', style: 'diagonal' }, + offset: { x: -5, y: 7 }, + }, + name: 'Inpaint Mask 2', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', + }, + target: 'inpaint_mask', + }, + { + expected: { + autoNegative: false, + blendMode: 'normal', + isEnabled: true, + isLocked: false, + mask: { + bitmap: resultImage, + fill: { color: '#fae150', style: 'solid' }, + offset: { x: -5, y: 7 }, + }, + name: 'Regional Guidance 2', + negativePrompt: null, + opacity: 0.5, + positivePrompt: null, + referenceImages: [], + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'regional_guidance', + }, + target: 'regional_guidance', + }, + ] as const)( + 'adds a complete $target directly above the source with one exact history entry', + async ({ expected, target }) => { + const document = docFor(target); + const { projectId, store } = createReducerBackedStore(document); + const bitmapStore = createSpyBitmapStore(); + const imageResolver = vi.fn(() => Promise.resolve(new Blob())); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver, + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels('source'); + if (exported.status !== 'ok') { + throw new Error('expected an export guard'); + } + + const result = await engine.layers.commitMaskImageResult({ + guard: exported.guard, + image: resultImage, + rect: resultRect, + target, + }); + + expect(result.status).toBe('committed'); + if (result.status !== 'committed') { + throw new Error('expected a committed mask'); + } + const created = engine.document.getDocument()!.layers.find((layer) => layer.id === result.layerId)!; + expect(created).toEqual({ ...expected, id: result.layerId }); + expect(engine.document.getDocument()!.layers.map((layer) => layer.id)).toEqual([ + document.layers[0]!.id, + result.layerId, + 'source', + 'below', + ]); + expect(engine.document.getDocument()!.selectedLayerId).toBe(result.layerId); + expect(imageResolver).not.toHaveBeenCalled(); + expect(bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(true); + + engine.history.undo(); + expect(engine.document.getDocument()).toEqual(document); + expect(engine.document.getDocument()!.selectedLayerId).toBe('below'); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + + engine.history.redo(); + expect(engine.document.getDocument()!.layers.find((layer) => layer.id === result.layerId)).toEqual(created); + expect(engine.document.getDocument()!.selectedLayerId).toBe(result.layerId); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + engine.lifecycle.dispose(); + } + ); + + it('commits and refreshes the document mirror when an earlier observer throws after the reducer adds the mask', async () => { + const document = docFor('inpaint_mask'); + const { projectId, store } = createReducerBackedStore(document); + const unsubscribeFault = store.subscribe(() => { + throw new Error('earlier mask observer failed'); + }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels('source'); + if (exported.status !== 'ok') { + throw new Error('expected an export guard'); + } + + const result = await engine.layers.commitMaskImageResult({ + guard: exported.guard, + image: resultImage, + rect: resultRect, + target: 'inpaint_mask', + }); + unsubscribeFault(); + + expect(result.status).toBe('committed'); + if (result.status !== 'committed') { + throw new Error('expected a committed mask'); + } + expect(engine.document.getDocument()).toBe( + store.getState().projects.find((project) => project.id === projectId)!.canvas.document + ); + expect(engine.document.getDocument()!.layers.some((layer) => layer.id === result.layerId)).toBe(true); + expect(engine.stores.canUndo.get()).toBe(true); + + engine.history.undo(); + expect(engine.document.getDocument()).toEqual(document); + engine.lifecycle.dispose(); + }); + + it('finishes mask undo exactly when document observers throw after both reducer mutations', async () => { + const document = docFor('regional_guidance'); + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels('source'); + if (exported.status !== 'ok') { + throw new Error('expected an export guard'); + } + const result = await engine.layers.commitMaskImageResult({ + guard: exported.guard, + image: resultImage, + rect: resultRect, + target: 'regional_guidance', + }); + if (result.status !== 'committed') { + throw new Error('expected a committed mask'); + } + const unsubscribeFault = store.subscribe(() => { + throw new Error('mask undo observer failed'); + }); + + expect(() => engine.history.undo()).not.toThrow(); + + expect(engine.document.getDocument()).toEqual(document); + expect(engine.document.getDocument()!.selectedLayerId).toBe('below'); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + unsubscribeFault(); + engine.lifecycle.dispose(); + }); + + it('keeps mask undo failure-atomic when restoring the prior selection fails before reducer application', async () => { + const document = docFor('inpaint_mask'); + const { dispatch, projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels('source'); + if (exported.status !== 'ok') { + throw new Error('expected an export guard'); + } + const result = await engine.layers.commitMaskImageResult({ + guard: exported.guard, + image: resultImage, + rect: resultRect, + target: 'inpaint_mask', + }); + if (result.status !== 'committed') { + throw new Error('expected a committed mask'); + } + const committedDocument = structuredClone(engine.document.getDocument()!); + const reducerDispatch = dispatch.getMockImplementation(); + if (!reducerDispatch) { + throw new Error('expected reducer-backed dispatch'); + } + let failSelection = true; + dispatch.mockImplementation((action: EngineTestAction) => { + if (failSelection && action.type === 'setCanvasSelectedLayer') { + failSelection = false; + throw new Error('mask selection restore failed'); + } + reducerDispatch(action); + }); + + expect(() => engine.history.undo()).toThrow('mask selection restore failed'); + + expect(engine.document.getDocument()).toEqual(committedDocument); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.history.undo(); + expect(engine.document.getDocument()).toEqual(document); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + engine.lifecycle.dispose(); + }); + + it('keeps mask undo retryable when removal fails after restoring the prior selection', async () => { + const document = docFor('regional_guidance'); + const { dispatch, projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const exported = await engine.exports.exportLayerPixels('source'); + if (exported.status !== 'ok') { + throw new Error('expected an export guard'); + } + const result = await engine.layers.commitMaskImageResult({ + guard: exported.guard, + image: resultImage, + rect: resultRect, + target: 'regional_guidance', + }); + if (result.status !== 'committed') { + throw new Error('expected a committed mask'); + } + const reducerDispatch = dispatch.getMockImplementation(); + if (!reducerDispatch) { + throw new Error('expected reducer-backed dispatch'); + } + let failRemoval = true; + dispatch.mockImplementation((action: EngineTestAction) => { + if (failRemoval && action.type === 'removeCanvasLayers') { + failRemoval = false; + throw new Error('mask removal failed'); + } + reducerDispatch(action); + }); + + expect(() => engine.history.undo()).toThrow('mask removal failed'); + + expect(engine.document.getDocument()!.layers.some((layer) => layer.id === result.layerId)).toBe(true); + expect(engine.document.getDocument()!.selectedLayerId).toBe('below'); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.history.undo(); + expect(engine.document.getDocument()).toEqual(document); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + engine.lifecycle.dispose(); + }); + + const guardHarness = async () => { + const layer = source(); + const document: CanvasDocumentContractV2 = { ...docFor('inpaint_mask'), layers: [layer] }; + const reactive = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store: reactive.store, + }); + const exported = await engine.exports.exportLayerPixels(layer.id); + if (exported.status !== 'ok') { + throw new Error('expected an export guard'); + } + (reactive.store.dispatch as Mock).mockClear(); + return { ...reactive, document, engine, guard: exported.guard, layer }; + }; + + it('returns aborted before any mask mutation', async () => { + const harness = await guardHarness(); + const controller = new AbortController(); + controller.abort(); + + await expect( + harness.engine.layers.commitMaskImageResult({ + guard: harness.guard, + image: resultImage, + rect: resultRect, + signal: controller.signal, + target: 'inpaint_mask', + }) + ).resolves.toEqual({ status: 'aborted' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it.each([ + { + expected: 'missing', + label: 'source deletion', + mutate: (harness: Awaited>) => + harness.setDocument({ ...harness.document, layers: [] }), + }, + { + expected: 'stale', + label: 'source contract edit', + mutate: (harness: Awaited>) => + harness.setDocument({ ...harness.document, layers: [{ ...harness.layer, opacity: 0.4 }] }), + }, + { + expected: 'locked', + label: 'source lock', + mutate: (harness: Awaited>) => + harness.setDocument({ ...harness.document, layers: [{ ...harness.layer, isLocked: true }] }), + }, + { + expected: 'unsupported', + label: 'source type change', + mutate: (harness: Awaited>) => { + const { source: _source, ...base } = harness.layer; + const mask: CanvasInpaintMaskLayerContract = { + ...base, + mask: { bitmap: null, fill: { color: '#fff', style: 'solid' } }, + type: 'inpaint_mask', + }; + harness.setDocument({ ...harness.document, layers: [mask] }); + }, + }, + { + expected: 'stale', + label: 'document replacement', + mutate: (harness: Awaited>) => harness.setDocument({ ...harness.document }, 1), + }, + { + expected: 'stale', + label: 'cache invalidation', + mutate: (harness: Awaited>) => harness.engine.diagnostics.clearCaches(), + }, + ] as const)('refuses $label without adding a mask or history', async ({ expected, mutate }) => { + const harness = await guardHarness(); + await mutate(harness); + + await expect( + harness.engine.layers.commitMaskImageResult({ + guard: harness.guard, + image: resultImage, + rect: resultRect, + target: 'regional_guidance', + }) + ).resolves.toEqual({ status: expected }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it('returns busy during an open pointer gesture without adding a mask', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const harness = await guardHarness(); + const screen = createInputCanvas(); + const overlay = createInputCanvas(); + harness.engine.surface.attach(screen.element, overlay.element); + raf.flush(); + overlay.fire('pointerdown', pointerAt(5, 5)); + + await expect( + harness.engine.layers.commitMaskImageResult({ + guard: harness.guard, + image: resultImage, + rect: resultRect, + target: 'inpaint_mask', + }) + ).resolves.toEqual({ status: 'busy' }); + expect(harness.store.dispatch).not.toHaveBeenCalled(); + expect(harness.engine.stores.canUndo.get()).toBe(false); + harness.engine.lifecycle.dispose(); + }); +}); + +describe('guarded filter previews', () => { + const spandrelModel = { + base: 'any', + hash: 'hash', + key: 'upscaler', + name: 'Upscaler', + type: 'spandrel_image_to_image', + }; + const emptyDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [], + selectedLayerId: null, + version: 2, + width: 100, + }); + + /** + * A layer that can be added/removed from the document to drive the mirror's + * `onLayersChanged`/`onDocumentReplaced` callbacks, without itself consuming a + * `createImageBitmap` call — a `bitmap: null` paint source rasterizes + * synchronously (a clear), unlike an image source, so it can't shift the call + * ordering the pruning tests rely on to target a SPECIFIC filter-preview decode. + */ + const previewableLayer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }); + + const guardableLayer = (id: string): CanvasRasterLayerContractV2 => ({ + ...(previewableLayer(id) as CanvasRasterLayerContractV2), + source: { fill: '#fff', height: 10, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 10 }, + }); + + const filterBitmapBackend = (width = 10, height = 10): StubRasterBackend => ({ + ...createTestStubRasterBackend(), + createImageBitmap: () => Promise.resolve({ close: () => undefined, height, width } as unknown as ImageBitmap), + }); + + it('auto-processes a debounced preview after a filter draft update', async () => { + const layer = guardableLayer('auto-filter'); + const { store } = createReactiveStore({ ...emptyDoc(), layers: [layer] }); + const runGraph = vi.fn(() => + Promise.resolve({ height: 10, imageName: 'auto.png', origin: 'test', output: {}, width: 10 }) + ); + const engine = createCanvasEngine({ + backend: filterBitmapBackend(), + filterDeps: { runGraph, uploadIntermediate: () => Promise.resolve({ imageName: 'input.png' }) }, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect((await engine.exports.exportLayerPixels(layer.id)).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation(layer.id)).toBe('started'); + expect(getCanvasOperations(engine).stores.filterSession.get()).toMatchObject({ + autoProcess: true, + status: 'ready', + }); + + getCanvasOperations(engine).updateFilterOperation({ + settings: { high_threshold: 210, low_threshold: 90 }, + type: 'canny_edge_detection', + }); + expect(runGraph).not.toHaveBeenCalled(); + await vi.waitFor( + () => + expect(getCanvasOperations(engine).stores.filterSession.get()).toMatchObject({ + preview: { imageName: 'auto.png' }, + status: 'ready', + }), + { timeout: 3000 } + ); + expect(runGraph).toHaveBeenCalledOnce(); + engine.lifecycle.dispose(); + }); + + it('setFilterOperationAutoProcess toggles the session and stops auto-runs', async () => { + const layer = guardableLayer('auto-filter-toggle'); + const { store } = createReactiveStore({ ...emptyDoc(), layers: [layer] }); + const runGraph = vi.fn(() => + Promise.resolve({ height: 10, imageName: 'auto.png', origin: 'test', output: {}, width: 10 }) + ); + const engine = createCanvasEngine({ + backend: filterBitmapBackend(), + filterDeps: { runGraph, uploadIntermediate: () => Promise.resolve({ imageName: 'input.png' }) }, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect((await engine.exports.exportLayerPixels(layer.id)).status).toBe('ok'); + + expect(getCanvasOperations(engine).setFilterOperationAutoProcess(false)).toBe('stale'); + + expect(getCanvasOperations(engine).startFilterOperation(layer.id)).toBe('started'); + expect(getCanvasOperations(engine).setFilterOperationAutoProcess(false)).toBe('updated'); + expect(getCanvasOperations(engine).stores.filterSession.get()).toMatchObject({ autoProcess: false }); + + getCanvasOperations(engine).updateFilterOperation({ + settings: { high_threshold: 210, low_threshold: 90 }, + type: 'canny_edge_detection', + }); + await new Promise((resolve) => { + setTimeout(resolve, FILTER_AUTO_PROCESS_DEBOUNCE_MS + 200); + }); + expect(runGraph).not.toHaveBeenCalled(); + + engine.tools.setInteractionLocked(true); + expect(getCanvasOperations(engine).setFilterOperationAutoProcess(true)).toBe('blocked'); + engine.lifecycle.dispose(); + }); + + it('clears the session and ends the operation after a committed filter commit', async () => { + const source = guardableLayer('commit-clear'); + const document = { ...emptyDoc(), layers: [source], selectedLayerId: source.id }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: filterBitmapBackend(), + bitmapStore: createSpyBitmapStore(), + filterDeps: { + runGraph: () => Promise.resolve({ height: 10, imageName: 'out.png', origin: 'test', output: {}, width: 10 }), + uploadIntermediate: () => Promise.resolve({ imageName: 'input.png' }), + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + expect((await engine.exports.exportLayerPixels(source.id)).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation(source.id)).toBe('started'); + await expect(getCanvasOperations(engine).processFilterOperation()).resolves.toBe('completed'); + + await expect(getCanvasOperations(engine).commitFilterOperation('apply', () => Promise.resolve())).resolves.toBe( + 'committed' + ); + + expect(getCanvasOperations(engine).stores.filterSession.get()).toBeNull(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + expect(getCanvasOperations(engine).startFilterOperation(source.id)).toBe('started'); + engine.lifecycle.dispose(); + }); + + it.each(['apply', 'raster'] as const)( + 'rejects a decoded dimension mismatch for canny %s without scaling, then retries successfully', + async (target) => { + const source = guardableLayer('dimension-source'); + const document = { ...emptyDoc(), layers: [source], selectedLayerId: source.id }; + const { projectId, store } = createReducerBackedStore(document); + const base = createTestStubRasterBackend(); + const decodedDimensions = [ + { height: 10, width: 9 }, + { height: 10, width: 10 }, + { height: 9, width: 10 }, + { height: 10, width: 10 }, + ]; + const decodedBitmaps: ImageBitmap[] = []; + const surfaces: StubRasterSurface[] = []; + const backend: StubRasterBackend = { + ...base, + createImageBitmap: () => { + const dimensions = decodedDimensions.shift(); + if (!dimensions) { + throw new Error('unexpected filter decode'); + } + const bitmap = { ...dimensions, close: vi.fn(), decodedFilter: true } as unknown as ImageBitmap; + decodedBitmaps.push(bitmap); + return Promise.resolve(bitmap); + }, + createSurface: (width, height) => { + const surface = base.createSurface(width, height); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + bitmapStore: createSpyBitmapStore(), + filterDeps: { + runGraph: () => + Promise.resolve({ height: 10, imageName: 'canny.png', origin: 'test', output: {}, width: 10 }), + uploadIntermediate: () => Promise.resolve({ imageName: 'input.png' }), + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + expect((await engine.exports.exportLayerPixels(source.id)).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation(source.id)).toBe('started'); + + await expect(getCanvasOperations(engine).processFilterOperation()).resolves.toBe('stale'); + expect(getCanvasOperations(engine).stores.filterSession.get()).toMatchObject({ + error: 'Canny Edge Detection output dimensions 9x10 do not match source dimensions 10x10.', + preview: null, + status: 'error', + }); + expect(engine.document.getDocument()).toEqual(document); + + await expect(getCanvasOperations(engine).processFilterOperation()).resolves.toBe('completed'); + await expect(getCanvasOperations(engine).commitFilterOperation(target, () => Promise.resolve())).resolves.toBe( + 'stale' + ); + expect(getCanvasOperations(engine).stores.filterSession.get()).toMatchObject({ + error: 'Canny Edge Detection output dimensions 10x9 do not match source dimensions 10x10.', + preview: { imageName: 'canny.png' }, + status: 'error', + }); + expect(engine.document.getDocument()).toEqual(document); + + await expect(getCanvasOperations(engine).commitFilterOperation(target, () => Promise.resolve())).resolves.toBe( + 'committed' + ); + + const bitmapDraws = surfaces.flatMap((surface) => + surface.callLog.filter( + (entry) => entry.op === 'drawImage' && decodedBitmaps.includes(entry.args[0] as ImageBitmap) + ) + ); + expect(bitmapDraws.length).toBeGreaterThan(0); + expect(bitmapDraws.every((entry) => entry.args.length === 3)).toBe(true); + expect(engine.document.getDocument()?.layers).toHaveLength(target === 'apply' ? 1 : 2); + engine.lifecycle.dispose(); + } + ); + + it('interaction lock blocks Filter and Select Object launches without creating sessions', async () => { + const layer = guardableLayer('locked-launch'); + const { store } = createReactiveStore({ ...emptyDoc(), layers: [layer] }); + const engine = createCanvasEngine({ + backend: filterBitmapBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect((await engine.exports.exportLayerPixels(layer.id)).status).toBe('ok'); + + engine.tools.setInteractionLocked(true); + + expect(getCanvasOperations(engine).startFilterOperation(layer.id)).toBe('locked'); + expect(getCanvasOperations(engine).startSelectObject(layer.id)).toBe('locked'); + expect(getCanvasOperations(engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + expect(getCanvasOperations(engine).stores.filterSession.get()).toBeNull(); + expect(getCanvasOperations(engine).stores.samSession.get()).toBeNull(); + engine.lifecycle.dispose(); + }); + + it('keeps an active Select Object operation available to cancel while interaction is locked', async () => { + const layer = guardableLayer('active-before-lock'); + const { store } = createReactiveStore({ ...emptyDoc(), layers: [layer] }); + const engine = createCanvasEngine({ + backend: filterBitmapBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect((await engine.exports.exportLayerPixels(layer.id)).status).toBe('ok'); + expect(getCanvasOperations(engine).startSelectObject(layer.id)).toBe('started'); + + engine.tools.setInteractionLocked(true); + + expect(getCanvasOperations(engine).controller.getSnapshot()).toMatchObject({ + identity: { kind: 'select-object' }, + status: 'active', + }); + expect(getCanvasOperations(engine).stores.samSession.get()).not.toBeNull(); + expect(engine.stores.activeTool.get()).toBe('view'); + + getCanvasOperations(engine).cancelSelectObjectSession(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + expect(getCanvasOperations(engine).stores.samSession.get()).toBeNull(); + + engine.tools.setInteractionLocked(false); + expect(engine.stores.activeTool.get()).toBe('view'); + engine.lifecycle.dispose(); + }); + + /** A stub backend whose `createImageBitmap` is deferred, so decodes resolve on demand. */ + const createDeferredBitmapBackend = () => { + const base = createTestStubRasterBackend(); + const deferreds: ReturnType>[] = []; + const backend: StubRasterBackend = { + ...base, + createImageBitmap: () => { + const deferred = createDeferred(); + deferreds.push(deferred); + return deferred.promise; + }, + }; + return { + backend, + resolveBitmap: (index: number): void => + deferreds[index]!.resolve({ close: () => {}, height: 0, width: 0 } as unknown as ImageBitmap), + }; + }; + + it('publishes a guarded filter preview while the exported layer snapshot remains current', async () => { + const bitmaps = createDeferredBitmapBackend(); + const document = { ...emptyDoc(), layers: [guardableLayer('L')] }; + const { store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const exported = await engine.exports.exportLayerPixels('L'); + if (exported.status !== 'ok') { + throw new Error('expected a guardable export'); + } + + const pending = engine.previews.setGuardedFilterPreview( + 'L', + { imageName: 'filtered', rect: exported.rect }, + exported.guard + ); + await flushMicrotasks(); + bitmaps.resolveBitmap(0); + + await expect(pending).resolves.toBe('shown'); + engine.lifecycle.dispose(); + }); + + it('owns a guarded filter session independently of the launching view', async () => { + const layer = { ...guardableLayer('L'), filter: { settings: { radius: 2 }, type: 'content_shuffle' } }; + const document = { ...emptyDoc(), layers: [layer] }; + const { store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: filterBitmapBackend(), + filterDeps: { + runGraph: vi.fn(() => + Promise.resolve({ height: 10, imageName: 'filtered', origin: 'canvas', output: {}, width: 10 }) + ), + uploadIntermediate: vi.fn(() => Promise.resolve({ imageName: 'input' })), + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect((await engine.exports.exportLayerPixels('L')).status).toBe('ok'); + + expect(getCanvasOperations(engine).startFilterOperation('L')).toBe('started'); + expect(getCanvasOperations(engine).stores.filterSession.get()).toMatchObject({ + draft: layer.filter, + initialFilter: layer.filter, + layerId: 'L', + layerName: layer.name, + layerType: 'raster', + status: 'ready', + }); + expect(getCanvasOperations(engine).controller.getSnapshot()).toMatchObject({ + identity: { kind: 'filter', layerId: 'L', projectId: 'p1' }, + status: 'active', + }); + await getCanvasOperations(engine).processFilterOperation(); + expect(getCanvasOperations(engine).stores.filterSession.get()).toMatchObject({ + preview: { imageName: 'filtered' }, + status: 'ready', + }); + + engine.lifecycle.dispose(); + }); + + it('starts an unfiltered layer with a recommendation but preserves an existing manual filter and Cancel', async () => { + const unfiltered = guardableLayer('recommended'); + const manual = { + ...guardableLayer('manual'), + filter: { settings: { coarse: true }, type: 'lineart_edge_detection' }, + }; + const document = { ...emptyDoc(), layers: [unfiltered, manual] }; + const { store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect((await engine.exports.exportLayerPixels('recommended')).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation('recommended', 'normal_map')).toBe('started'); + expect(getCanvasOperations(engine).stores.filterSession.get()?.draft).toEqual({ settings: {}, type: 'normal_map' }); + getCanvasOperations(engine).cancelFilterOperation(); + expect(engine.document.getDocument()).toEqual({ ...document, selectedLayerId: 'recommended' }); + + expect((await engine.exports.exportLayerPixels('manual')).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation('manual', 'normal_map')).toBe('not-ready'); + expect(getCanvasOperations(engine).stores.filterSession.get()).toBeNull(); + expect(getCanvasOperations(engine).startFilterOperation('manual')).toBe('started'); + expect(getCanvasOperations(engine).stores.filterSession.get()?.draft).toEqual(manual.filter); + getCanvasOperations(engine).cancelFilterOperation(); + expect(engine.document.getDocument()).toEqual({ ...document, selectedLayerId: 'manual' }); + engine.lifecycle.dispose(); + }); + + it('does not replace another layer filter session with a recommendation', async () => { + const first = guardableLayer('first'); + const second = guardableLayer('second'); + const document = { ...emptyDoc(), layers: [first, second] }; + const { store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect((await engine.exports.exportLayerPixels('first')).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation('first')).toBe('started'); + getCanvasOperations(engine).updateFilterOperation({ settings: { coarse: true }, type: 'lineart_edge_detection' }); + const active = getCanvasOperations(engine).stores.filterSession.get(); + + expect((await engine.exports.exportLayerPixels('second')).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation('second', 'normal_map')).toBe('not-ready'); + expect(getCanvasOperations(engine).stores.filterSession.get()).toEqual(active); + engine.lifecycle.dispose(); + }); + + it('does not overwrite a same-layer draft or preview on rapid recommendations', async () => { + const layer = guardableLayer('recommended'); + const document = { ...emptyDoc(), layers: [layer] }; + const { store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + filterDeps: { + runGraph: vi.fn(() => + Promise.resolve({ height: 10, imageName: 'filtered', origin: 'canvas', output: {}, width: 10 }) + ), + uploadIntermediate: vi.fn(() => Promise.resolve({ imageName: 'input' })), + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect((await engine.exports.exportLayerPixels(layer.id)).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation(layer.id, 'normal_map')).toBe('started'); + getCanvasOperations(engine).updateFilterOperation({ settings: { coarse: true }, type: 'lineart_edge_detection' }); + await getCanvasOperations(engine).processFilterOperation(); + const active = getCanvasOperations(engine).stores.filterSession.get(); + + expect(getCanvasOperations(engine).startFilterOperation(layer.id, 'canny_edge_detection')).toBe('not-ready'); + expect(getCanvasOperations(engine).stores.filterSession.get()).toEqual(active); + engine.lifecycle.dispose(); + }); + + it('does not replace a different active canvas operation with a recommendation', async () => { + const first = guardableLayer('first'); + const second = guardableLayer('second'); + const document = { ...emptyDoc(), layers: [first, second] }; + const { store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect((await engine.exports.exportLayerPixels('first')).status).toBe('ok'); + expect((await engine.exports.exportLayerPixels('second')).status).toBe('ok'); + expect(getCanvasOperations(engine).startSelectObject('first')).toBe('started'); + const operation = getCanvasOperations(engine).controller.getSnapshot(); + + expect(getCanvasOperations(engine).startFilterOperation('second', 'normal_map')).toBe('not-ready'); + expect(getCanvasOperations(engine).controller.getSnapshot()).toEqual(operation); + expect(getCanvasOperations(engine).stores.samSession.get()?.sourceRect).toEqual({ + height: 10, + width: 10, + x: 0, + y: 0, + }); + engine.lifecycle.dispose(); + }); + + const filterFlowLayer = ( + type: 'raster' | 'control' + ): Extract => { + const base = { + blendMode: 'multiply' as const, + filter: { settings: { radius: 2 }, type: 'content_shuffle' }, + id: 'filter-source', + isEnabled: true, + isLocked: false, + name: 'Filter source', + opacity: 0.6, + source: { + bitmap: { height: 10, imageName: 'filter-source.png', width: 10 }, + offset: { x: 7, y: -3 }, + type: 'paint' as const, + }, + transform: { rotation: 12, scaleX: 2, scaleY: 3, x: 40, y: 50 }, + }; + return type === 'raster' + ? { ...base, type: 'raster' } + : { + ...base, + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + type: 'control', + withTransparencyEffect: true, + }; + }; + + const createFilterFlowHarness = async (sourceType: 'raster' | 'control') => { + const source = filterFlowLayer(sourceType); + const document = { ...emptyDoc(), layers: [source], selectedLayerId: source.id }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: filterBitmapBackend(), + bitmapStore: createSpyBitmapStore(), + filterDeps: { + runGraph: vi.fn(() => + Promise.resolve({ height: 10, imageName: 'filtered.png', origin: 'canvas', output: {}, width: 10 }) + ), + uploadIntermediate: vi.fn(() => Promise.resolve({ imageName: 'filter-input.png' })), + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + expect((await engine.exports.exportLayerPixels(source.id)).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation(source.id)).toBe('started'); + await getCanvasOperations(engine).processFilterOperation(); + expect(getCanvasOperations(engine).stores.filterSession.get()).toMatchObject({ + preview: { imageName: 'filtered.png' }, + status: 'ready', + }); + return { document, engine, source }; + }; + + it('blocks Filter actions called directly after an external lock and still allows Cancel', async () => { + const { document, engine } = await createFilterFlowHarness('raster'); + const makeDurable = vi.fn(() => Promise.resolve()); + const before = getCanvasOperations(engine).stores.filterSession.get(); + + engine.tools.setInteractionLocked(true); + + expect(getCanvasOperations(engine).updateFilterOperation({ settings: { radius: 99 }, type: 'img_blur' })).toBe( + 'blocked' + ); + expect(getCanvasOperations(engine).resetFilterOperation({ radius: 0 })).toBe('blocked'); + await expect(getCanvasOperations(engine).processFilterOperation()).resolves.toBe('blocked'); + await expect(getCanvasOperations(engine).commitFilterOperation('apply', makeDurable)).resolves.toBe('blocked'); + expect(makeDurable).not.toHaveBeenCalled(); + expect(engine.document.getDocument()).toEqual(document); + expect(getCanvasOperations(engine).stores.filterSession.get()).toEqual(before); + + getCanvasOperations(engine).cancelFilterOperation(); + expect(getCanvasOperations(engine).stores.filterSession.get()).toBeNull(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + engine.tools.setInteractionLocked(false); + expect(getCanvasOperations(engine).stores.filterSession.get()).toBeNull(); + expect(getCanvasOperations(engine).updateFilterOperation({ settings: {}, type: 'img_blur' })).toBe('stale'); + expect(getCanvasOperations(engine).resetFilterOperation({})).toBe('stale'); + engine.lifecycle.dispose(); + }); + + it('blocks Filter mutation when an external lock begins during durability', async () => { + const { document, engine } = await createFilterFlowHarness('raster'); + const durable = createDeferred(); + const pending = getCanvasOperations(engine).commitFilterOperation('apply', () => durable.promise); + await vi.waitFor(() => expect(getCanvasOperations(engine).stores.filterSession.get()?.status).toBe('committing')); + + engine.tools.setInteractionLocked(true); + durable.resolve(); + + await expect(pending).resolves.toBe('blocked'); + expect(engine.document.getDocument()).toEqual(document); + expect(getCanvasOperations(engine).stores.filterSession.get()).toMatchObject({ + preview: { imageName: 'filtered.png' }, + status: 'ready', + }); + getCanvasOperations(engine).cancelFilterOperation(); + engine.lifecycle.dispose(); + }); + + it('interrupts Filter processing on an external lock without losing the draft or operation', async () => { + const source = filterFlowLayer('raster'); + const { projectId, store } = createReducerBackedStore({ + ...emptyDoc(), + layers: [source], + selectedLayerId: source.id, + }); + const graph = createDeferred<{ height: number; imageName: string; origin: string; output: {}; width: number }>(); + const runGraph = vi.fn(() => graph.promise); + const engine = createCanvasEngine({ + backend: filterBitmapBackend(), + bitmapStore: createSpyBitmapStore(), + filterDeps: { + runGraph, + uploadIntermediate: vi.fn(() => Promise.resolve({ imageName: 'filter-input.png' })), + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + expect((await engine.exports.exportLayerPixels(source.id)).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation(source.id)).toBe('started'); + expect(getCanvasOperations(engine).updateFilterOperation({ settings: { radius: 7 }, type: 'img_blur' })).toBe( + 'updated' + ); + const pending = getCanvasOperations(engine).processFilterOperation(); + await vi.waitFor(() => expect(runGraph).toHaveBeenCalledOnce()); + + engine.tools.setInteractionLocked(true); + graph.resolve({ height: 10, imageName: 'late.png', origin: 'test', output: {}, width: 10 }); + + await expect(pending).resolves.toBe('stale'); + expect(getCanvasOperations(engine).stores.filterSession.get()).toMatchObject({ + draft: { settings: { radius: 7 }, type: 'img_blur' }, + preview: null, + status: 'ready', + }); + expect(getCanvasOperations(engine).controller.getSnapshot()).toMatchObject({ phase: 'ready', status: 'active' }); + getCanvasOperations(engine).cancelFilterOperation(); + engine.lifecycle.dispose(); + }); + + it.each([ + { + expectedRect: { height: 22, width: 22, x: 1, y: -9 }, + output: { height: 22, width: 22 }, + settings: { blur_type: 'gaussian', radius: 2 }, + target: 'apply', + type: 'img_blur', + }, + { + expectedRect: { height: 14, width: 14, x: 5, y: -5 }, + output: { height: 14, width: 14 }, + settings: { blur_type: 'box', radius: 2 }, + target: 'raster', + type: 'img_blur', + }, + { + expectedRect: { height: 30, width: 40, x: 7, y: -3 }, + output: { height: 30, width: 40 }, + settings: { autoScale: true, model: spandrelModel, scale: 4 }, + target: 'control', + type: 'spandrel_filter', + }, + ] as const)( + 'preserves $type/$target output geometry through preview, commit, and one-entry undo/redo', + async ({ expectedRect, output, settings, target, type }) => { + const source = filterFlowLayer('raster'); + const document = { ...emptyDoc(), layers: [source], selectedLayerId: source.id }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: filterBitmapBackend(output.width, output.height), + bitmapStore: createSpyBitmapStore(), + filterDeps: { + runGraph: vi.fn(() => + Promise.resolve({ ...output, imageName: 'filtered.png', origin: 'canvas', output: {} }) + ), + uploadIntermediate: vi.fn(() => Promise.resolve({ imageName: 'filter-input.png' })), + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + expect((await engine.exports.exportLayerPixels(source.id)).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation(source.id)).toBe('started'); + getCanvasOperations(engine).updateFilterOperation({ settings: structuredClone(settings), type }); + + await getCanvasOperations(engine).processFilterOperation(); + expect(getCanvasOperations(engine).stores.filterSession.get()?.preview?.rect).toEqual(expectedRect); + await getCanvasOperations(engine).commitFilterOperation(target, () => Promise.resolve()); + + const committedId = target === 'apply' ? source.id : engine.document.getDocument()!.layers[0]!.id; + const committed = engine.document.getDocument()!.layers.find((layer) => layer.id === committedId)!; + expect(committed.transform).toEqual(source.transform); + if (!('source' in committed)) { + throw new Error('expected a raster or control filter result'); + } + expect(committed.source).toMatchObject({ + bitmap: { height: output.height, width: output.width }, + offset: { x: expectedRect.x, y: expectedRect.y }, + type: 'paint', + }); + const committedExport = await engine.exports.exportLayerPixels(committedId); + expect(committedExport.status === 'ok' ? committedExport.rect : null).toEqual(expectedRect); + expect(engine.stores.canUndo.get()).toBe(true); + + engine.history.undo(); + expect(engine.document.getDocument()).toEqual(document); + expect(engine.stores.canUndo.get()).toBe(false); + engine.history.redo(); + const redone = engine.document.getDocument()!.layers.find((layer) => layer.id === committedId)!; + expect(redone).toEqual(committed); + expect(engine.stores.canRedo.get()).toBe(false); + engine.lifecycle.dispose(); + } + ); + + it.each(['raster', 'control'] as const)( + 'processes, retries durability, and applies %s with one origin-preserving history entry', + async (sourceType) => { + const { document, engine, source } = await createFilterFlowHarness(sourceType); + const makeDurable = vi + .fn<(imageName: string) => Promise>() + .mockRejectedValueOnce(new Error('promotion failed')) + .mockResolvedValueOnce(); + + await getCanvasOperations(engine).commitFilterOperation('apply', makeDurable); + expect(getCanvasOperations(engine).stores.filterSession.get()).toMatchObject({ + error: 'promotion failed', + preview: { imageName: 'filtered.png' }, + status: 'error', + }); + expect(getCanvasOperations(engine).controller.getSnapshot()).toMatchObject({ + identity: { kind: 'filter' }, + status: 'active', + }); + + await getCanvasOperations(engine).commitFilterOperation('apply', makeDurable); + expect(makeDurable).toHaveBeenCalledTimes(2); + expect(getCanvasOperations(engine).stores.filterSession.get()).toBeNull(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + expect(engine.document.getDocument()!.layers[0]).toMatchObject({ + filter: source.filter, + opacity: source.opacity, + blendMode: source.blendMode, + source: { offset: { x: 7, y: -3 }, type: 'paint' }, + transform: source.transform, + type: sourceType, + }); + expect(engine.stores.canUndo.get()).toBe(true); + engine.history.undo(); + expect(engine.document.getDocument()).toEqual(document); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + } + ); + + it.each(['raster', 'control'] as const)( + 'processes and saves as %s above the source with one history entry', + async (target) => { + const { engine, source } = await createFilterFlowHarness('control'); + + await getCanvasOperations(engine).commitFilterOperation(target, () => Promise.resolve()); + + expect(getCanvasOperations(engine).stores.filterSession.get()).toBeNull(); + expect(engine.document.getDocument()!.layers.map((layer) => layer.type)).toEqual([target, 'control']); + expect(engine.document.getDocument()!.layers[0]).toMatchObject({ + blendMode: source.blendMode, + opacity: source.opacity, + source: { offset: { x: 7, y: -3 }, type: 'paint' }, + transform: source.transform, + }); + expect(engine.stores.canUndo.get()).toBe(true); + engine.history.undo(); + expect(engine.document.getDocument()!.layers).toEqual([source]); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + } + ); + + it('cancels an in-flight promotion when a replacement operation starts', async () => { + const { engine, source } = await createFilterFlowHarness('raster'); + let resolvePromotion!: () => void; + const promotion = new Promise((resolve) => { + resolvePromotion = resolve; + }); + const pending = getCanvasOperations(engine).commitFilterOperation('apply', () => promotion); + await vi.waitFor(() => expect(getCanvasOperations(engine).stores.filterSession.get()?.status).toBe('committing')); + + expect(getCanvasOperations(engine).startSelectObject(source.id)).toBe('started'); + resolvePromotion(); + await pending; + + expect(engine.document.getDocument()!.layers[0]).toEqual(source); + expect(getCanvasOperations(engine).stores.filterSession.get()).toBeNull(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toMatchObject({ + identity: { kind: 'select-object' }, + status: 'active', + }); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('aborts an in-flight upload when a newer Process request wins', async () => { + const source = filterFlowLayer('raster'); + const document = { ...emptyDoc(), layers: [source], selectedLayerId: source.id }; + const { projectId, store } = createReducerBackedStore(document); + const uploadSignals: AbortSignal[] = []; + let uploadCount = 0; + const engine = createCanvasEngine({ + backend: filterBitmapBackend(), + bitmapStore: createSpyBitmapStore(), + filterDeps: { + runGraph: () => + Promise.resolve({ height: 10, imageName: 'newest-filter.png', origin: 'canvas', output: {}, width: 10 }), + uploadIntermediate: (_blob, signal) => { + if (!signal) { + throw new Error('expected upload cancellation signal'); + } + uploadSignals.push(signal); + uploadCount += 1; + if (uploadCount === 2) { + return Promise.resolve({ imageName: 'newest-input.png' }); + } + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new DOMException('superseded', 'AbortError')), { + once: true, + }); + }); + }, + }, + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + expect((await engine.exports.exportLayerPixels(source.id)).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation(source.id)).toBe('started'); + + const older = getCanvasOperations(engine).processFilterOperation(); + await vi.waitFor(() => expect(uploadSignals).toHaveLength(1)); + const newer = getCanvasOperations(engine).processFilterOperation(); + await Promise.all([older, newer]); + + expect(uploadSignals[0]?.aborted).toBe(true); + expect(getCanvasOperations(engine).stores.filterSession.get()).toMatchObject({ + error: null, + preview: { imageName: 'newest-filter.png' }, + status: 'ready', + }); + engine.lifecycle.dispose(); + }); + + it('makes filter and Select Object operations mutually exclusive', async () => { + const document = { ...emptyDoc(), layers: [guardableLayer('L')] }; + const { store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect((await engine.exports.exportLayerPixels('L')).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation('L')).toBe('started'); + + expect(getCanvasOperations(engine).startSelectObject('L')).toBe('started'); + + expect(getCanvasOperations(engine).stores.filterSession.get()).toBeNull(); + expect(getCanvasOperations(engine).stores.samSession.get()).not.toBeNull(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toMatchObject({ + identity: { kind: 'select-object' }, + status: 'active', + }); + engine.lifecycle.dispose(); + }); + + it('refuses ordinary structural editing while a filter operation is active', async () => { + const document = { ...emptyDoc(), layers: [guardableLayer('L')] }; + const { store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect((await engine.exports.exportLayerPixels('L')).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation('L')).toBe('started'); + vi.mocked(store.dispatch).mockClear(); + + engine.layers.commitStructural( + 'Rename', + { id: 'L', patch: { name: 'Changed' }, type: 'updateCanvasLayer' }, + { id: 'L', patch: { name: 'L' }, type: 'updateCanvasLayer' } + ); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.tools.handleEscapePriority({ gestureWasActive: false }); + expect(getCanvasOperations(engine).stores.filterSession.get()).toBeNull(); + engine.lifecycle.dispose(); + }); + + it.each(['filter', 'select-object'] as const)( + 'centrally blocks edits and undo during %s, then restores them after cancel', + async (kind) => { + const layer = guardableLayer('L'); + const document = { ...emptyDoc(), layers: [layer], selectedLayerId: layer.id }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + expect((await engine.exports.exportLayerPixels('L')).status).toBe('ok'); + engine.layers.commitStructural( + 'Rename', + { id: 'L', patch: { name: 'Renamed' }, type: 'updateCanvasLayer' }, + { id: 'L', patch: { name: 'L' }, type: 'updateCanvasLayer' } + ); + const guard = engine.exports.captureLayerExportGuard('L'); + if (!guard) { + throw new Error('expected a current operation guard'); + } + const operation = + kind === 'filter' + ? getCanvasOperations(engine).controller.start({ + cleanupPreview: vi.fn(), + guard, + identity: { kind, layerId: 'L', projectId }, + }) + : null; + if (kind === 'select-object') { + expect(getCanvasOperations(engine).startSelectObject('L')).toBe('started'); + } + + expect(engine.stores.documentEditingLocked.get()).toBe(true); + engine.history.undo(); + expect( + engine.layers.applyStructuralPreview({ id: 'L', patch: { opacity: 0.2 }, type: 'updateCanvasLayer' }) + ).toBe(false); + engine.layers.commitStructural( + 'Blocked rename', + { id: 'L', patch: { name: 'Blocked' }, type: 'updateCanvasLayer' }, + { id: 'L', patch: { name: 'Renamed' }, type: 'updateCanvasLayer' } + ); + engine.layers.nudgeSelectedLayer(5, 0); + await expect( + engine.layers.commitRasterFilterResult({ + guard, + image: { height: 10, imageName: 'unauthorized.png', width: 10 }, + mode: 'replace', + rect: { height: 10, width: 10, x: 0, y: 0 }, + }) + ).resolves.toEqual({ status: 'busy' }); + + expect(engine.document.getDocument()!.layers[0]).toMatchObject({ name: 'Renamed', transform: { x: 0 } }); + expect(getCanvasOperations(engine).controller.getSnapshot()).toMatchObject({ + identity: { kind }, + status: 'active', + }); + + if (kind === 'select-object') { + getCanvasOperations(engine).cancelSelectObjectSession(); + } else { + operation?.cancel(); + } + expect(engine.stores.documentEditingLocked.get()).toBe(false); + engine.history.undo(); + expect(engine.document.getDocument()!.layers[0]?.name).toBe('L'); + engine.lifecycle.dispose(); + } + ); + + it('does not clear history while document editing is locked', async () => { + const layer = guardableLayer('L'); + const document = { ...emptyDoc(), layers: [layer] }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + expect((await engine.exports.exportLayerPixels(layer.id)).status).toBe('ok'); + engine.layers.commitStructural( + 'Rename', + { id: layer.id, patch: { name: 'Renamed' }, type: 'updateCanvasLayer' }, + { id: layer.id, patch: { name: layer.name }, type: 'updateCanvasLayer' } + ); + const guard = engine.exports.captureLayerExportGuard(layer.id); + if (!guard) { + throw new Error('expected an operation guard'); + } + const operation = getCanvasOperations(engine).controller.start({ + cleanupPreview: vi.fn(), + guard, + identity: { kind: 'filter', layerId: layer.id, projectId }, + }); + + engine.history.clearHistory(); + + expect(engine.stores.canUndo.get()).toBe(true); + expect(getCanvasOperations(engine).controller.getSnapshot()).toMatchObject({ + identity: { kind: 'filter' }, + status: 'active', + }); + operation?.cancel(); + engine.history.clearHistory(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('invalidates the owned filter session when its guarded source changes', async () => { + const layer = guardableLayer('L'); + const document = { ...emptyDoc(), layers: [layer] }; + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect((await engine.exports.exportLayerPixels('L')).status).toBe('ok'); + expect(getCanvasOperations(engine).startFilterOperation('L')).toBe('started'); + expect(getCanvasOperations(engine).stores.filterSession.get()).toMatchObject({ + draft: { type: 'canny_edge_detection' }, + initialFilter: null, + }); + + setDocument({ ...document, layers: [{ ...layer, opacity: 0.5 }] }); + + expect(getCanvasOperations(engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + expect(getCanvasOperations(engine).stores.filterSession.get()).toBeNull(); + engine.lifecycle.dispose(); + }); + + it('rejects a guarded filter preview when its source changes during decode', async () => { + const bitmaps = createDeferredBitmapBackend(); + const layer = guardableLayer('L'); + const document = { ...emptyDoc(), layers: [layer] }; + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const exported = await engine.exports.exportLayerPixels('L'); + if (exported.status !== 'ok') { + throw new Error('expected a guardable export'); + } + + const pending = engine.previews.setGuardedFilterPreview( + 'L', + { imageName: 'filtered', rect: exported.rect }, + exported.guard + ); + await flushMicrotasks(); + setDocument({ + ...document, + layers: [{ ...layer, source: { ...layer.source, fill: '#000' } } as CanvasLayerContract], + }); + bitmaps.resolveBitmap(0); + + await expect(pending).resolves.toBe('stale'); + engine.lifecycle.dispose(); + }); + + it('rejects a guarded filter preview when raster adjustments change during decode', async () => { + const bitmaps = createDeferredBitmapBackend(); + const layer = guardableLayer('L'); + const document = { ...emptyDoc(), layers: [layer] }; + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const exported = await engine.exports.exportLayerPixels('L'); + if (exported.status !== 'ok') { + throw new Error('expected a guardable export'); + } + + const pending = engine.previews.setGuardedFilterPreview( + 'L', + { imageName: 'filtered', rect: exported.rect }, + exported.guard + ); + await flushMicrotasks(); + setDocument({ + ...document, + layers: [{ ...layer, adjustments: { brightness: 0.2, contrast: 0, saturation: 0 } }], + }); + bitmaps.resolveBitmap(0); + + await expect(pending).resolves.toBe('stale'); + engine.lifecycle.dispose(); + }); + + it('rejects a guarded filter preview when the paint cache version changes during decode', async () => { + const bitmaps = createDeferredBitmapBackend(); + const document = { ...emptyDoc(), layers: [guardableLayer('L')] }; + const { store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const exported = await engine.exports.exportLayerPixels('L'); + if (exported.status !== 'ok') { + throw new Error('expected a guardable export'); + } + + const pending = engine.previews.setGuardedFilterPreview( + 'L', + { imageName: 'filtered', rect: exported.rect }, + exported.guard + ); + await flushMicrotasks(); + await engine.diagnostics.clearCaches(); + bitmaps.resolveBitmap(0); + + await expect(pending).resolves.toBe('stale'); + engine.lifecycle.dispose(); + }); + + it('returns missing when the guarded preview layer is deleted during decode', async () => { + const bitmaps = createDeferredBitmapBackend(); + const document = { ...emptyDoc(), layers: [guardableLayer('L')] }; + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const exported = await engine.exports.exportLayerPixels('L'); + if (exported.status !== 'ok') { + throw new Error('expected a guardable export'); + } + + const pending = engine.previews.setGuardedFilterPreview( + 'L', + { imageName: 'filtered', rect: exported.rect }, + exported.guard + ); + await flushMicrotasks(); + setDocument({ ...document, layers: [] }); + bitmaps.resolveBitmap(0); + + await expect(pending).resolves.toBe('missing'); + engine.lifecycle.dispose(); + }); + + it('rejects a guarded filter preview after document replacement reuses the layer id', async () => { + const bitmaps = createDeferredBitmapBackend(); + const document = { ...emptyDoc(), layers: [guardableLayer('L')] }; + const { setDocument, store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const exported = await engine.exports.exportLayerPixels('L'); + if (exported.status !== 'ok') { + throw new Error('expected a guardable export'); + } + + const pending = engine.previews.setGuardedFilterPreview( + 'L', + { imageName: 'filtered', rect: exported.rect }, + exported.guard + ); + await flushMicrotasks(); + setDocument({ ...document, height: 200, width: 200 }, 1); + bitmaps.resolveBitmap(0); + + await expect(pending).resolves.toBe('stale'); + engine.lifecycle.dispose(); + }); + + it('lets only the newest guarded filter preview publish', async () => { + const bitmaps = createDeferredBitmapBackend(); + const document = { ...emptyDoc(), layers: [guardableLayer('L')] }; + const { store } = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const exported = await engine.exports.exportLayerPixels('L'); + if (exported.status !== 'ok') { + throw new Error('expected a guardable export'); + } + + const older = engine.previews.setGuardedFilterPreview( + 'L', + { imageName: 'older', rect: exported.rect }, + exported.guard + ); + const newer = engine.previews.setGuardedFilterPreview( + 'L', + { imageName: 'newer', rect: exported.rect }, + exported.guard + ); + await flushMicrotasks(); + bitmaps.resolveBitmap(1); + await expect(newer).resolves.toBe('shown'); + bitmaps.resolveBitmap(0); + + await expect(older).resolves.toBe('stale'); + engine.lifecycle.dispose(); + }); + + const screenDrawsBitmap = (screen: StubRasterSurface, backend: RecordingRasterBackend, bitmapId: string): boolean => + screen.callLog + .filter((entry) => entry.op === 'drawImage') + .some((entry) => { + const surfaceId = (entry.args[0] as { __recordingId?: string }).__recordingId; + const surface = surfaceId ? backend.surfaceById(surfaceId) : undefined; + return !!surface && backend.drawSourcesFor(surface).includes(`bitmap-${bitmapId}`); + }); + + const createPublishedGuardedPreview = async (layer: CanvasRasterLayerContractV2 = guardableLayer('L')) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const backend = createRecordingRasterBackend(); + const sourceNeedsBitmap = + layer.type === 'raster' && + (layer.source.type === 'image' || (layer.source.type === 'paint' && layer.source.bitmap !== null)); + let bitmapCall = 0; + backend.createImageBitmap = vi.fn(() => + Promise.resolve(recordingBitmap(sourceNeedsBitmap && bitmapCall++ === 0 ? 'layer-source' : 'guarded-preview')) + ); + const document = { ...emptyDoc(), layers: [layer], selectedLayerId: layer.id }; + const reactive = createReactiveStore(document); + const engine = createCanvasEngine({ + backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store: reactive.store, + }); + const screen = createFakeCanvas(); + engine.surface.attach(screen.element, createFakeCanvas().element); + raf.flush(); + const exported = await engine.exports.exportLayerPixels(layer.id); + if (exported.status !== 'ok') { + throw new Error('expected a guardable export'); + } + await expect( + engine.previews.setGuardedFilterPreview( + layer.id, + { imageName: 'guarded-preview', rect: exported.rect }, + exported.guard + ) + ).resolves.toBe('shown'); + raf.flush(); + expect(screenDrawsBitmap(screen.surface, backend, 'guarded-preview')).toBe(true); + screen.surface.callLog.length = 0; + return { backend, document, engine, layer, raf, screen: screen.surface, ...reactive }; + }; + + it('keeps a published guarded preview across an active-project away/back transition', async () => { + const harness = await createPublishedGuardedPreview(); + + harness.setActiveProjectId('p2'); + harness.setActiveProjectId('p1'); + harness.screen.callLog.length = 0; + harness.engine.stores.checkerboard.set(!harness.engine.stores.checkerboard.get()); + harness.raf.flush(); + + expect(screenDrawsBitmap(harness.screen, harness.backend, 'guarded-preview')).toBe(true); + harness.engine.lifecycle.dispose(); + }); + + it('publishes an in-flight guarded preview across an active-project away/back transition', async () => { + const bitmaps = createDeferredBitmapBackend(); + const document = { ...emptyDoc(), layers: [guardableLayer('L')] }; + const reactive = createReactiveStore(document); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store: reactive.store, + }); + const exported = await engine.exports.exportLayerPixels('L'); + if (exported.status !== 'ok') { + throw new Error('expected a guardable export'); + } + const pending = engine.previews.setGuardedFilterPreview( + 'L', + { imageName: 'filtered', rect: exported.rect }, + exported.guard + ); + await flushMicrotasks(); + + reactive.setActiveProjectId('p2'); + reactive.setActiveProjectId('p1'); + bitmaps.resolveBitmap(0); + + await expect(pending).resolves.toBe('shown'); + engine.lifecycle.dispose(); + }); + + it('unsubscribes project-preview lifecycle handling on dispose', () => { + const reactive = createReactiveStore(emptyDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store: reactive.store, + }); + + expect(reactive.listenerCount()).toBe(1); + engine.lifecycle.dispose(); + expect(reactive.listenerCount()).toBe(0); + }); + + it('clears an already-published guarded preview when the source contract changes', async () => { + const harness = await createPublishedGuardedPreview(); + harness.setDocument({ + ...harness.document, + layers: [{ ...harness.layer, source: { ...harness.layer.source, fill: '#000' } } as CanvasLayerContract], + }); + harness.raf.flush(); + + expect(screenDrawsBitmap(harness.screen, harness.backend, 'guarded-preview')).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it('clears an already-published guarded preview when raster adjustments change', async () => { + const harness = await createPublishedGuardedPreview(); + harness.setDocument({ + ...harness.document, + layers: [{ ...harness.layer, adjustments: { brightness: 0.3, contrast: 0, saturation: 0 } }], + }); + harness.raf.flush(); + + expect(screenDrawsBitmap(harness.screen, harness.backend, 'guarded-preview')).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it('clears an already-published guarded preview when live paint advances the cache version', async () => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const paint: CanvasRasterLayerContractV2 = { + ...guardableLayer('L'), + source: { bitmap: { height: 10, imageName: 'paint-source', width: 10 }, type: 'paint' }, + type: 'raster', + }; + const harness = await createPublishedGuardedPreview(paint); + + harness.engine.selection.selectAll(); + harness.engine.selection.fillSelection(); + harness.raf.flush(); + + expect(screenDrawsBitmap(harness.screen, harness.backend, 'guarded-preview')).toBe(false); + harness.engine.lifecycle.dispose(); + }); + + it('clears an already-published guarded preview when its cache is invalidated', async () => { + const harness = await createPublishedGuardedPreview(); + + await harness.engine.diagnostics.clearCaches(); + harness.raf.flush(); + + expect(screenDrawsBitmap(harness.screen, harness.backend, 'guarded-preview')).toBe(false); + harness.engine.lifecycle.dispose(); + }); +}); + +// ---- C1: prop/transform edits must not wipe an unflushed paint layer ----- +// +// A `bitmap: null` paint layer's strokes live ONLY in its raster cache until a +// debounced upload persists them. The paint rasterizer clears the surface for a +// null bitmap, so re-rasterizing such a layer WIPES the strokes. The engine must +// therefore invalidate a layer's cache only when its SOURCE reference changed — +// never for a prop/transform-only edit (opacity/blend/lock/rename/nudge), which +// the compositor already applies at draw time. + +/** Full-surface clears (`clearRect(0,0,w,h)`) recorded on a stub surface — the wipe signature. */ +const fullClearCount = (surface: StubRasterSurface): number => + surface.callLog.filter((entry) => entry.op === 'clearRect' && entry.args[0] === 0 && entry.args[1] === 0).length; + +describe('document mirror wiring: prop vs source change (paint-pixel survival)', () => { + /** + * Rasterizes the pre-existing `paint1` layer to completion (as real frames + * would before the user paints — the existing-layer paint path does NOT mark + * the cache non-stale itself), then draws one stroke into that non-stale cache. + * This reproduces the C1 state: real strokes living only in a cache that a + * spurious re-rasterize (of the `bitmap: null` source) would clear to blank. + */ + const paintOneStroke = async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { setDocument, store } = createReactiveStore(paintDoc()); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const bitmapStore = createSpyBitmapStore(); + const resolver = vi.fn((_imageName: string) => Promise.resolve(new Blob())); + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: resolver, + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('brush'); + + // Initial rasterize of the (bitmap: null) paint layer → one full clear, then + // the cache settles non-stale (the `.then` fires on a microtask). + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + + // The painted layer cache is the only engine-backend surface that ever gets + // `getImageData` (the before/after stroke capture); the scratch stroke + // surface never does. + const paintCache = surfaces.find((surface) => surface.callLog.some((entry) => entry.op === 'getImageData')); + return { bitmapStore, engine, paintCache: paintCache!, raf, resolver, setDocument }; + }; + + const paintOneStrokeWithReducer = async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + const { projectId, store } = createReducerBackedStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('brush'); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + return engine; + }; + + it('accepts a persisted bitmap from reducer state when an earlier subscriber throws before the mirror refreshes', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + const reducer = createReducerBackedStore(paintDoc()); + let throwAfterBitmapCommit = false; + const unsubscribeThrower = reducer.store.subscribe(() => { + if (throwAfterBitmapCommit) { + throw new Error('earlier subscriber failed after reducer commit'); + } + }); + const reportError = vi.fn(); + const uploadImage = vi + .spyOn(canvasApplicationPort, 'uploadImage') + .mockResolvedValue({ height: 40, imageName: 'persisted-before-mirror-refresh.png', width: 60 }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: reducer.projectId, + reportError, + store: reducer.store, + }); + const overlay = createInputCanvas(); + engine.surface.attach(createInputCanvas().element, overlay.element); + engine.tools.setTool('brush'); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + const strokes: StrokeCommittedEvent[] = []; + const unsubscribeStroke = engine.tools.onStrokeCommitted((stroke) => strokes.push(stroke)); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + expect(strokes).toHaveLength(1); + expect(strokes[0]?.layerId).toBe('paint1'); + throwAfterBitmapCommit = true; + + await expect(engine.lifecycle.flushPendingUploads()).resolves.toBeUndefined(); + await expect(engine.lifecycle.flushPendingUploads()).resolves.toBeUndefined(); + + const authoritativeLayer = reducer.store + .getState() + .projects.find((project) => project.id === reducer.projectId) + ?.canvas.document.layers.find((layer) => layer.id === 'paint1'); + expect(uploadImage).toHaveBeenCalledOnce(); + const bitmapUpdates = reducer.dispatch.mock.calls + .map(([action]) => action) + .filter((action) => action.type === 'updateCanvasLayerSource' && action.id === 'paint1'); + expect(bitmapUpdates).toHaveLength(1); + expect(authoritativeLayer).toMatchObject({ + source: { bitmap: { imageName: 'persisted-before-mirror-refresh.png' }, type: 'paint' }, + }); + expect(reportError).not.toHaveBeenCalled(); + + unsubscribeStroke(); + unsubscribeThrower(); + engine.lifecycle.dispose(); + uploadImage.mockRestore(); + }); + + it('keeps an unflushed paint layer’s pixels on a transform/opacity-only change (no re-rasterize)', async () => { + const { engine, paintCache, raf, resolver, setDocument } = await paintOneStroke(); + + // Baseline: the stroke composited into the cache (a drawImage). Exactly one + // full clear so far — the initial rasterize; the stroke never clears. + expect(paintCache.callLog.some((entry) => entry.op === 'drawImage')).toBe(true); + const clearsBefore = fullClearCount(paintCache); + + // A prop-only edit that PRESERVES the source reference (exactly as the reducer + // does — it spreads `...layer`): opacity + a transform nudge. + const doc = engine.document.getDocument()!; + const layer = doc.layers[0]!; + setDocument({ + ...doc, + layers: [{ ...layer, opacity: 0.5, transform: { ...layer.transform, x: 12, y: -4 } }], + }); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + // The cache was NOT re-rasterized: no new full clear, so the painted pixels + // survive. (A `bitmap: null` re-rasterize would clear the surface to blank.) + expect(fullClearCount(paintCache)).toBe(clearsBefore); + // No resolve/rasterize was even attempted for the paint layer. + expect(resolver).not.toHaveBeenCalled(); + expect(engine.document.getDocument()!.layers[0]!.opacity).toBe(0.5); + + engine.lifecycle.dispose(); + }); + + it('exports an unflushed paint layer from its ready live cache when the contract bitmap is still null', async () => { + const { engine, paintCache } = await paintOneStroke(); + + const result = await engine.exports.exportLayerPixels('paint1'); + + expect(result.status).toBe('ok'); + if (result.status === 'ok') { + expect(result.surface).toBe(paintCache); + expect(result.rect.width).toBeGreaterThan(0); + expect(result.rect.height).toBeGreaterThan(0); + } + engine.lifecycle.dispose(); + }); + + it('preserves live pixels through contract copy and conversion history', async () => { + const engine = await paintOneStrokeWithReducer(); + const raster = engine.document.getDocument()!.layers[0]!; + if (raster.type !== 'raster') { + throw new Error('expected raster source'); + } + const control: CanvasLayerContract = { + ...raster, + adapter: { + beginEndStepPct: [0, 0.75], + controlMode: 'balanced', + kind: 'controlnet', + model: null, + weight: 1, + }, + type: 'control', + withTransparencyEffect: true, + }; + const copy = { ...control, id: 'control-copy', name: 'Control copy' }; + + expect(engine.layers.commitLayerCopy('Copy layer', raster.id, copy, 0)).toBe(true); + expect((await engine.exports.exportLayerPixels(copy.id)).status).toBe('ok'); + engine.history.undo(); + expect(engine.document.getDocument()!.layers.some((layer) => layer.id === copy.id)).toBe(false); + engine.history.redo(); + expect((await engine.exports.exportLayerPixels(copy.id)).status).toBe('ok'); + + expect(engine.layers.commitLayerConversion('Convert layer', raster, control)).toBe(true); + expect(engine.document.getDocument()!.layers.find((layer) => layer.id === raster.id)?.type).toBe('control'); + expect((await engine.exports.exportLayerPixels(raster.id)).status).toBe('ok'); + engine.history.undo(); + expect(engine.document.getDocument()!.layers.find((layer) => layer.id === raster.id)?.type).toBe('raster'); + expect((await engine.exports.exportLayerPixels(raster.id)).status).toBe('ok'); + engine.lifecycle.dispose(); + }); + + it('commitLayerConversion requires the caller to hold the immutable live layer object', async () => { + const { dispatch, projectId, store } = createReducerBackedStore(makeDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + expect((await engine.exports.exportLayerPixels('a')).status).toBe('ok'); + const live = engine.document.getDocument()!.layers[0]!; + if (live.type !== 'raster') { + throw new Error('expected raster layer'); + } + const converted: CanvasLayerContract = { + ...live, + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + type: 'control', + withTransparencyEffect: false, + }; + dispatch.mockClear(); + + expect(engine.layers.commitLayerConversion('Convert', structuredClone(live), converted)).toBe(false); + expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'convertCanvasLayer' })); + expect(engine.document.getDocument()!.layers[0]!.type).toBe('raster'); + engine.lifecycle.dispose(); + }); + + it('commitLayerConversion refuses conversion when the live layer is locked', async () => { + const { dispatch, projectId, store } = createReducerBackedStore(makeDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + expect((await engine.exports.exportLayerPixels('a')).status).toBe('ok'); + const expectedUnlocked = engine.document.getDocument()!.layers[0]!; + if (expectedUnlocked.type !== 'raster') { + throw new Error('expected raster layer'); + } + const converted: CanvasLayerContract = { + ...expectedUnlocked, + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + type: 'control', + withTransparencyEffect: false, + }; + dispatch({ id: expectedUnlocked.id, patch: { isLocked: true }, type: 'updateCanvasLayer' }); + dispatch.mockClear(); + + expect(engine.layers.commitLayerConversion('Convert', expectedUnlocked, converted)).toBe(false); + expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'convertCanvasLayer' })); + expect(engine.document.getDocument()!.layers[0]).toMatchObject({ isLocked: true, type: 'raster' }); + engine.lifecycle.dispose(); + }); + + it('flushing after a prop-only change persists the painted (non-blank) surface', async () => { + const { bitmapStore, engine, paintCache, raf, setDocument } = await paintOneStroke(); + const clearsBefore = fullClearCount(paintCache); + + const doc = engine.document.getDocument()!; + const layer = doc.layers[0]!; + setDocument({ ...doc, layers: [{ ...layer, opacity: 0.25 }] }); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + await engine.lifecycle.flushPendingUploads(); + + // The flush barrier ran, and it operated on a cache that was never wiped: the + // surface still carries the stroke (drawImage) with no re-rasterize clear, so + // the bitmap store (which encodes this exact surface) persists real pixels. + expect(bitmapStore.flushPendingUploads).toHaveBeenCalled(); + expect(fullClearCount(paintCache)).toBe(clearsBefore); + expect(paintCache.callLog.some((entry) => entry.op === 'drawImage')).toBe(true); + + engine.lifecycle.dispose(); + }); + + it('re-rasterizes when the paint layer’s source genuinely changes (swap to a persisted bitmap)', async () => { + const { engine, raf, resolver, setDocument } = await paintOneStroke(); + expect(resolver).not.toHaveBeenCalled(); + + // A genuine source swap (undo/import → a NEW paint source object with a + // persisted bitmap). isSelfEcho is false in the spy store, so this must + // invalidate and re-rasterize — which decodes the persisted image. + const doc = engine.document.getDocument()!; + const layer = doc.layers[0] as CanvasRasterLayerContractV2; + setDocument({ + ...doc, + layers: [ + { + ...layer, + source: { bitmap: { contentHash: 'h', height: 100, imageName: 'persisted', width: 100 }, type: 'paint' }, + }, + ], + }); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + // The cache was invalidated and re-rasterized from the new persisted source. + expect(resolver).toHaveBeenCalledWith('persisted', expect.any(AbortSignal)); + + engine.lifecycle.dispose(); + }); + + it('leaves image layers alone on a prop change but re-rasterizes on a source swap', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { setDocument, store } = createReactiveStore(makeDoc()); // one image layer 'a' + const resolver = vi.fn((_imageName: string) => Promise.resolve(new Blob())); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + engine.surface.attach(createFakeCanvas().element, createFakeCanvas().element); + + // Initial rasterize of image 'a'. + raf.flush(); + await flushMicrotasks(); + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenNthCalledWith(1, 'a', expect.any(AbortSignal)); + + // Prop-only edit (opacity), source reference preserved: no re-rasterize. + const doc = engine.document.getDocument()!; + setDocument({ ...doc, layers: [{ ...doc.layers[0]!, opacity: 0.3 }] }); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(1); + + // Source swap (new image name → new source object): re-rasterizes the new source. + const doc2 = engine.document.getDocument()!; + const imgLayer = doc2.layers[0] as CanvasRasterLayerContractV2; + setDocument({ + ...doc2, + layers: [{ ...imgLayer, source: { image: { height: 10, imageName: 'a-v2', width: 10 }, type: 'image' } }], + }); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(2); + expect(resolver).toHaveBeenNthCalledWith(2, 'a-v2', expect.any(AbortSignal)); + + engine.lifecycle.dispose(); + }); +}); + +describe('hasExportableLayerContent', () => { + const sourceLayer = (id: string, source: CanvasLayerSourceContract, isEnabled = true): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled, + isLocked: false, + name: id, + opacity: 1, + source, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }); + + const createContentEngine = (layers: CanvasLayerContract[]) => { + const { store } = createFakeStore({ ...makeDoc(), layers }); + return createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + }; + + const createLiveUnpersistedLayer = async (layer: CanvasLayerContract) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { setDocument, store } = createReactiveStore({ + ...makeDoc(), + layers: [layer], + selectedLayerId: layer.id, + }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + engine.surface.attach(createInputCanvas().element, overlay.element); + + // Settle the initial empty paint/mask rasterization before drawing. A stroke + // then grows that current cache without updating the persisted contract. + raf.flush(); + await flushMicrotasks(); + raf.flush(); + engine.tools.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + + return { engine, setDocument }; + }; + + it('returns true for image, persisted paint, and every supported parametric source', () => { + const sources: { id: string; source: CanvasLayerSourceContract }[] = [ + { + id: 'image', + source: { image: { height: 12, imageName: 'image', width: 14 }, type: 'image' }, + }, + { + id: 'persisted-paint', + source: { + bitmap: { height: 15, imageName: 'paint', width: 16 }, + offset: { x: -3, y: 4 }, + type: 'paint', + }, + }, + { + id: 'rect', + source: { fill: '#000', height: 18, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 20 }, + }, + { + id: 'ellipse', + source: { + fill: '#000', + height: 18, + kind: 'ellipse', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 20, + }, + }, + { + id: 'gradient', + source: { + angle: 45, + height: 22, + kind: 'linear', + stops: [{ color: '#000', offset: 0 }], + type: 'gradient', + width: 24, + }, + }, + { + id: 'text', + source: { + align: 'left', + color: '#000', + content: 'Export me', + fontFamily: 'Inter', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + }, + }, + ]; + // Disabled layers remain exportable because Save/Clipboard may explicitly + // export hidden content. + const engine = createContentEngine(sources.map(({ id, source }) => sourceLayer(id, source, false))); + + for (const { id } of sources) { + expect(engine.exports.hasExportableLayerContent(id), id).toBe(true); + } + engine.lifecycle.dispose(); + }); + + it('returns false for empty paint, unsupported polygon, and missing ids', () => { + const polygon: CanvasLayerSourceContract = { + fill: '#000', + height: 20, + kind: 'polygon', + points: [ + { x: 0, y: 0 }, + { x: 20, y: 0 }, + { x: 10, y: 20 }, + ], + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 20, + }; + const engine = createContentEngine([ + sourceLayer('empty-paint', { bitmap: null, type: 'paint' }), + sourceLayer('polygon', polygon), + ]); + + expect(engine.exports.hasExportableLayerContent('empty-paint')).toBe(false); + expect(engine.exports.hasExportableLayerContent('polygon')).toBe(false); + expect(engine.exports.hasExportableLayerContent('missing')).toBe(false); + engine.lifecycle.dispose(); + }); + + it('returns true for a persisted disabled mask and false for an empty mask', () => { + const persistedMask: CanvasInpaintMaskLayerContract = { + ...maskLayer('persisted-mask'), + isEnabled: false, + mask: { + ...maskLayer('persisted-mask').mask, + bitmap: { height: 17, imageName: 'persisted-mask', width: 19 }, + offset: { x: 2, y: 3 }, + }, + }; + const engine = createContentEngine([persistedMask, maskLayer('empty-mask')]); + + expect(engine.exports.hasExportableLayerContent('persisted-mask')).toBe(true); + expect(engine.exports.hasExportableLayerContent('empty-mask')).toBe(false); + engine.lifecycle.dispose(); + }); + + it('returns true for current live unpersisted paint pixels', async () => { + const { engine } = await createLiveUnpersistedLayer(sourceLayer('live-paint', { bitmap: null, type: 'paint' })); + + expect(engine.exports.hasExportableLayerContent('live-paint')).toBe(true); + engine.lifecycle.dispose(); + }); + + it('captures current unflushed pixels and bounds for a new bitmap-less paint layer', async () => { + const { engine } = await createLiveUnpersistedLayer( + sourceLayer('live-snapshot-paint', { bitmap: null, type: 'paint' }) + ); + const documentSnapshot = engine.document.captureSnapshot(); + + const result = await engine.exports.captureRasterSnapshot(documentSnapshot!, ['live-snapshot-paint']); + + expect(result.status).toBe('ok'); + if (result.status !== 'ok') { + throw new Error('Expected live paint snapshot'); + } + const detached = result.snapshot.layerSurfaces.get('live-snapshot-paint'); + expect(detached?.rect.width).toBeGreaterThan(0); + expect(detached?.rect.height).toBeGreaterThan(0); + result.snapshot.release(); + engine.lifecycle.dispose(); + }); + + it('returns false for a stale non-empty live cache with no persisted pixels', async () => { + const { engine, setDocument } = await createLiveUnpersistedLayer( + sourceLayer('stale-paint', { bitmap: null, type: 'paint' }) + ); + expect(engine.exports.hasExportableLayerContent('stale-paint')).toBe(true); + + const doc = engine.document.getDocument()!; + const layer = doc.layers[0]; + if (!layer || layer.type !== 'raster') { + throw new Error('expected raster paint layer'); + } + // A genuine source-reference change invalidates the still-non-empty live + // cache synchronously. Do not run the scheduled frame that would rebuild it. + setDocument({ + ...doc, + layers: [{ ...layer, source: { bitmap: null, type: 'paint' } }], + }); + const { target } = createThumbnailTarget(); + expect(engine.previews.drawLayerThumbnail('stale-paint', target, 96)).toBe(true); + expect(engine.exports.hasExportableLayerContent('stale-paint')).toBe(false); + engine.lifecycle.dispose(); + }); + + it('returns true for current live unpersisted mask pixels', async () => { + const { engine } = await createLiveUnpersistedLayer(maskLayer('live-mask')); + + expect(engine.exports.hasExportableLayerContent('live-mask')).toBe(true); + engine.lifecycle.dispose(); + }); +}); + +// ---- I4: structural edits are no-ops during an active pointer gesture ----- + +describe('gesture guard: nudge / commitStructural mid-stroke', () => { + const startOpenStroke = () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('brush'); + // Open a stroke (pointer down + move, NO up) so the gesture stays active. + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(30, 30)); + return { dispatch, engine, overlay }; + }; + + it('no-ops nudgeSelectedLayer while a stroke gesture is open', () => { + const { dispatch, engine, overlay } = startOpenStroke(); + const before = dispatch.mock.calls.length; + + engine.layers.nudgeSelectedLayer(1, 0); + // No structural transform dispatch, no history entry. + expect(dispatch.mock.calls.filter((call) => call[0].type === 'updateCanvasLayer')).toHaveLength(0); + expect(engine.stores.canUndo.get()).toBe(false); + + // After the gesture ends, the nudge lands. + overlay.fire('pointerup', pointerAt(30, 30, { buttons: 0 })); + engine.layers.nudgeSelectedLayer(1, 0); + expect(dispatch.mock.calls.length).toBeGreaterThan(before); + expect(dispatch.mock.calls.some((call) => call[0].type === 'updateCanvasLayer')).toBe(true); + + engine.lifecycle.dispose(); + }); + + it('no-ops commitStructural while a stroke gesture is open, then commits after it ends', () => { + const { dispatch, engine, overlay } = startOpenStroke(); + const forward: EngineTestAction = { id: 'x', type: 'setCanvasSelectedLayer' }; + const inverse: EngineTestAction = { id: null, type: 'setCanvasSelectedLayer' }; + + expect(engine.layers.canCommitStructural()).toBe(false); + expect(engine.layers.commitStructural('Select', forward, inverse)).toBe(false); + // Nothing dispatched, nothing recorded on history mid-gesture. + expect(dispatch.mock.calls.some((call) => call[0] === forward)).toBe(false); + expect(engine.stores.canUndo.get()).toBe(false); + + overlay.fire('pointerup', pointerAt(30, 30, { buttons: 0 })); + expect(engine.layers.canCommitStructural()).toBe(true); + expect(engine.layers.commitStructural('Select', forward, inverse)).toBe(true); + expect(dispatch.mock.calls.some((call) => call[0] === forward)).toBe(true); + expect(engine.stores.canUndo.get()).toBe(true); + + engine.lifecycle.dispose(); + }); + + it('no-ops mergeLayerDown while a stroke gesture is open, then merges after it ends', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(twoPaintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + // Build both layer caches so a merge could otherwise succeed; await the async + // decode so both are READY (merge refuses stale/in-flight caches — finding 20). + raf.flush(); + await flushMicrotasks(); + raf.flush(); + engine.tools.setTool('brush'); + + // Open a stroke into the selected 'upper' paint layer (no pointer-up). + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(30, 30)); + + // Mid-gesture merge is refused (matches commitStructural/nudge): not undoable. + expect(engine.layers.mergeLayerDown('upper')).toBe(false); + expect(dispatch.mock.calls.some((call) => (call[0] as EngineTestAction).type === 'mergeCanvasLayersDown')).toBe( + false + ); + + // After the gesture ends, the merge lands. + overlay.fire('pointerup', pointerAt(30, 30, { buttons: 0 })); + expect(engine.layers.mergeLayerDown('upper')).toBe(true); + expect(dispatch.mock.calls.some((call) => (call[0] as EngineTestAction).type === 'mergeCanvasLayersDown')).toBe( + true + ); + + engine.lifecycle.dispose(); + }); +}); + +// ---- brush cursor ring: resizes on a size change with no pointer event ---- + +describe('brush cursor ring: live size updates', () => { + it('invalidates the overlay when the brush size changes without a pointer move', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('brush'); + + // A bare hover move (no button) sets the cursor ring at a known position. + overlay.fire('pointermove', pointerAt(30, 30, { buttons: 0 })); + raf.flush(); + expect(raf.pendingCount()).toBe(0); + + // The `[`/`]` path: a size step with NO pointer event must schedule a frame + // (the ring redraws at its last position with the new radius). + engine.tools.stepBrushSize(1); + expect(raf.pendingCount()).toBeGreaterThan(0); + raf.flush(); + + // The options-bar slider path (a direct store write) likewise invalidates. + engine.stores.brushOptions.set({ ...engine.stores.brushOptions.get(), size: 123 }); + expect(raf.pendingCount()).toBeGreaterThan(0); + + engine.lifecycle.dispose(); + }); + + it('does not invalidate for a size change while no ring is shown (pointer off-canvas)', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('brush'); + raf.flush(); + expect(raf.pendingCount()).toBe(0); + + // No hover move happened, so there is no ring to resize: no frame scheduled. + engine.tools.stepBrushSize(1); + expect(raf.pendingCount()).toBe(0); + + engine.lifecycle.dispose(); + }); +}); + +// ---- doc-replace mid-gesture: cancels the active tool gesture (I-follow-up) ---- + +describe('doc-replace mid-gesture: cancels the active tool gesture', () => { + it('cancels a bbox drag on a document swap, clears the preview, and commits nothing on pointer-up', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { setDocument, store } = createReactiveStore(paintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('bbox'); + + // Start a bbox move-drag (press inside the 100x100 frame, then drag). + overlay.fire('pointerdown', pointerAt(40, 40)); + overlay.fire('pointermove', pointerAt(60, 60)); + expect(engine.stores.bboxPreview.get()).not.toBeNull(); + + // A wholesale document swap mid-drag (dims change → onDocumentReplaced). + setDocument({ ...paintDoc(), height: 200, width: 200 }); + + // The gesture was cancelled: the transient preview is cleared. + expect(engine.stores.bboxPreview.get()).toBeNull(); + + // The eventual pointer-up must NOT commit a bbox against the replaced document. + overlay.fire('pointerup', pointerAt(60, 60, { buttons: 0 })); + expect(dispatch.mock.calls.some((call) => (call[0] as EngineTestAction).type === 'setCanvasBbox')).toBe(false); + expect(engine.stores.canUndo.get()).toBe(false); + + engine.lifecycle.dispose(); + }); + + it('cancels a move-tool drag on a document swap, clearing the transform override', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { setDocument, store } = createReactiveStore(paintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('move'); + + // Drag the doc-sized paint layer. + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(50, 40)); + + const updatesBefore = dispatch.mock.calls.filter( + (call) => (call[0] as EngineTestAction).type === 'updateCanvasLayer' + ).length; + + setDocument({ ...paintDoc(), height: 200, width: 200 }); + + // Pointer-up after the swap commits no transform update (the gesture was cancelled). + overlay.fire('pointerup', pointerAt(50, 40, { buttons: 0 })); + const updatesAfter = dispatch.mock.calls.filter( + (call) => (call[0] as EngineTestAction).type === 'updateCanvasLayer' + ).length; + expect(updatesAfter).toBe(updatesBefore); + expect(engine.stores.canUndo.get()).toBe(false); + + engine.lifecycle.dispose(); + }); +}); + +// ---- Zoom-cost regressions: what must NOT scale with zoom ---------------- +// +// The reported lag ("laggier the closer you zoom in") traced to the render loop +// recompositing the whole document on EVERY invalidation — including overlay-only +// hover frames, whose cost is otherwise constant. A full composite up-scales each +// doc-sized layer surface to fill the screen, so its fill-rate grows with zoom. +// These lock the two fixes: overlay-only frames skip the composite, and composites +// disable image smoothing when zoomed in (crisp + no bilinear up-scale per frame). + +describe('zoom-cost: overlay-only frames skip the document composite', () => { + /** A fake canvas that can BOTH fire pointer events and expose its recording surface. */ + const createFireableSurfaceCanvas = ( + width = 100, + height = 100 + ): { + element: HTMLCanvasElement; + fire: (type: string, event: Partial) => void; + surface: StubRasterSurface; + } => { + const surface = createTestStubRasterBackend().createSurface(width, height); + const listeners = new Map void>>(); + const element = { + addEventListener: (type: string, handler: (event: Event) => void) => { + const set = listeners.get(type) ?? new Set(); + set.add(handler); + listeners.set(type, set); + }, + getBoundingClientRect: () => ({ bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0 }), + getContext: () => surface.ctx, + height, + releasePointerCapture: () => {}, + removeEventListener: (type: string, handler: (event: Event) => void) => { + listeners.get(type)?.delete(handler); + }, + setPointerCapture: () => {}, + width, + } as unknown as HTMLCanvasElement; + const fire = (type: string, event: Partial): void => { + for (const handler of listeners.get(type) ?? []) { + handler({ preventDefault: () => {}, ...event } as unknown as Event); + } + }; + return { element, fire, surface }; + }; + + /** Composite draws land on the screen surface as clear/fill/blit ops. */ + const compositeOps = (surface: StubRasterSurface): RasterCallLogEntry[] => + surface.callLog.filter((e) => e.op === 'drawImage' || e.op === 'clearRect' || e.op === 'fillRect'); + + it('a hover pointermove redraws only the overlay, never the screen composite', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFireableSurfaceCanvas(); + const overlay = createFireableSurfaceCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('brush'); + + // Drain the attach `{ all }` frame and the paint layer's async rasterize + // follow-up so no composite-triggering work is left pending. + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + // Isolate the hover frame. + screen.surface.callLog.length = 0; + overlay.surface.callLog.length = 0; + + // A hover move (no button pressed) resizes the cursor ring → `{ overlay: true }`. + overlay.fire('pointermove', pointerAt(40, 40, { buttons: 0 })); + raf.flush(); + + // The screen composite did NOT run: zero clears/fills/blits landed on it. This + // is the win — hover cost is now independent of zoom and document size. + expect(compositeOps(screen.surface)).toHaveLength(0); + // The overlay WAS redrawn: cleared, and the cursor ring arc drawn. + expect(overlay.surface.callLog.some((e) => e.op === 'clearRect')).toBe(true); + expect(overlay.surface.callLog.some((e) => e.op === 'arc')).toBe(true); + + engine.lifecycle.dispose(); + }); + + it('composites with smoothing OFF when zoomed in and ON when zoomed out', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const findSet = (surface: StubRasterSurface, prop: string): unknown[] => + surface.callLog.filter((e) => e.op === 'set' && e.args[0] === prop).map((e) => e.args[1]); + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + // Zoom in to 20× → a view invalidation → recomposite with smoothing off. + screen.surface.callLog.length = 0; + engine.viewport.getViewport().zoomAtPoint(20, { x: 0, y: 0 }); + raf.flush(); + expect(findSet(screen.surface, 'imageSmoothingEnabled')).toContain(false); + + // Zoom out below 1× → recomposite with smoothing on (clean down-scale). + screen.surface.callLog.length = 0; + engine.viewport.getViewport().zoomAtPoint(0.5, { x: 0, y: 0 }); + raf.flush(); + expect(findSet(screen.surface, 'imageSmoothingEnabled')).toContain(true); + + engine.lifecycle.dispose(); + }); + + it('builds the checkerboard pattern tile once across many composited frames', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + // Count surfaces allocated at the checkerboard tile size (CHECKERBOARD_SQUARE_PX * 2 = 16). + const base = createTestStubRasterBackend(); + const tileSurfaceSizes: string[] = []; + const backend: StubRasterBackend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + if (w === 16 && h === 16) { + tileSurfaceSizes.push(`${w}x${h}`); + } + return base.createSurface(w, h); + }, + }; + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + // Checkerboard is on by default; the transparent paintDoc fills with it. + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + + // Force several composited frames (each recomposites and re-`createPattern`s). + for (let i = 0; i < 5; i++) { + engine.viewport.getViewport().zoomAtPoint(1 + i, { x: 0, y: 0 }); + raf.flush(); + await flushMicrotasks(); + } + + // The tile surface was built exactly once and reused (no per-frame rebuild). + expect(tileSurfaceSizes).toEqual(['16x16']); + + engine.lifecycle.dispose(); + }); +}); + +// ---- checker colors: theme-token feed → tile rebuild + recomposite --------- + +const fillStyleSets = (surface: StubRasterSurface): unknown[] => + surface.callLog.filter((e) => e.op === 'set' && e.args[0] === 'fillStyle').map((e) => e.args[1]); + +describe('checker colors: fed-token tile rebuild', () => { + it('builds the fallback-colored tile when never fed, then rebuilds with fed colors and recomposites', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + // Capture every 16x16 checker tile surface (CHECKERBOARD_SQUARE_PX * 2). + const base = createTestStubRasterBackend(); + const tiles: StubRasterSurface[] = []; + const backend: StubRasterBackend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + if (w === 16 && h === 16) { + tiles.push(surface); + } + return surface; + }, + }; + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + + // First composite built the tile once, using the React-free fallback colors. + expect(tiles).toHaveLength(1); + expect(fillStyleSets(tiles[0]!)).toEqual([DEFAULT_CHECKER_COLORS.a, DEFAULT_CHECKER_COLORS.b]); + + // Feed new (resolved-token) checker colors: the cached tile is dropped and + // rebuilt with the new colors on the next composite, which is forced to run. + engine.stores.checkerColors.set({ a: '#010101', b: '#020202' }); + raf.flush(); + await flushMicrotasks(); + + expect(tiles).toHaveLength(2); + expect(fillStyleSets(tiles[1]!)).toEqual(['#010101', '#020202']); + + engine.lifecycle.dispose(); + }); + + it('does not rebuild the tile when fed colors are unchanged (equality-gated store)', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const base = createTestStubRasterBackend(); + const tiles: StubRasterSurface[] = []; + const backend: StubRasterBackend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + if (w === 16 && h === 16) { + tiles.push(surface); + } + return surface; + }, + }; + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + expect(tiles).toHaveLength(1); + + // Re-feeding the SAME colors is a no-op (the store's equality check drops it), + // so no invalidation and no tile rebuild. + engine.stores.checkerColors.set({ ...DEFAULT_CHECKER_COLORS }); + raf.flush(); + await flushMicrotasks(); + expect(tiles).toHaveLength(1); + + engine.lifecycle.dispose(); + }); +}); + +// ---- fitToView: content ∪ bbox (document rect retired as world bounds) ------ + +const noLayerDoc = (overrides: Partial = {}): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 1000, + layers: [], + selectedLayerId: null, + version: 2, + width: 1000, + ...overrides, +}); + +describe('fitToView: content ∪ bbox', () => { + it('fits the bbox (not the larger doc rect) on an empty canvas', () => { + const { store } = createReactiveStore(noLayerDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + engine.surface.resize(400, 400, 1); + + engine.viewport.fitToView(); + + // avail = 400 - 48*2 = 304; fitting the 100px bbox → 3.04. Fitting the 1000px + // doc rect would have been ~0.304 — the doc rect is no longer the fit target. + expect(engine.viewport.getViewport().getZoom()).toBeCloseTo(3.04, 2); + engine.lifecycle.dispose(); + }); + + it('unions a renderable layer that lies beyond the bbox into the fit', () => { + // A raster layer 100x100 translated to (900,900): content extends to 1000, far + // past the 100px bbox. Fitting content ∪ bbox spans 0..1000 → zoom ~0.304. + const layer: CanvasLayerContract = { + blendMode: 'normal', + id: 'a', + isEnabled: true, + isLocked: false, + name: 'a', + opacity: 1, + source: { image: { height: 100, imageName: 'a', width: 100 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 900, y: 900 }, + type: 'raster', + }; + const { store } = createReactiveStore(noLayerDoc({ layers: [layer] })); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + engine.surface.resize(400, 400, 1); + + engine.viewport.fitToView(); + + // Union spans (0,0)..(1000,1000) = 1000px; avail 304 → 0.304. + expect(engine.viewport.getViewport().getZoom()).toBeCloseTo(0.304, 2); + engine.lifecycle.dispose(); + }); +}); + +// ---- transform session: param commit (image) + pixel bake (paint) ---------- + +describe('transform session', () => { + it('image layer Apply commits one structural transform with the exact inverse', () => { + const { store } = createReactiveStore(selectedImageDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + engine.tools.setTool('transform'); + engine.layers.updateTransformSession({ rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 }); + dispatch.mockClear(); + engine.layers.applyTransform(); + + const layerDispatches = dispatch.mock.calls + .map((call) => call[0] as EngineTestAction) + .filter((action) => action.type === 'updateCanvasLayer'); + // Exactly one structural dispatch (the forward transform); no pixel work. + expect(layerDispatches).toHaveLength(1); + expect(layerDispatches[0]).toEqual({ + id: 'a', + patch: { transform: { rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 } }, + type: 'updateCanvasLayer', + }); + expect(engine.stores.transformSession.get()).toBeNull(); + expect(engine.stores.canUndo.get()).toBe(true); + + // Undo dispatches the exact inverse (the captured start transform). + dispatch.mockClear(); + engine.history.undo(); + const undoDispatch = dispatch.mock.calls + .map((call) => call[0] as EngineTestAction) + .find((action) => action.type === 'updateCanvasLayer'); + expect(undoDispatch).toEqual({ + id: 'a', + patch: { transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 } }, + type: 'updateCanvasLayer', + }); + + engine.lifecycle.dispose(); + }); + + it('parametric (text) layer Apply commits ONE param transform, stays type text, no bake; undo restores', () => { + // Regression: parametric layers (shape/gradient/text) could not be transformed + // — `applyTransform` only handled image sources. Phase 5 "param for parametric": + // the transform commits as a param edit and the source stays editable-forever. + const textLayerDoc: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'normal', + id: 't', + isEnabled: true, + isLocked: false, + name: 'Text', + opacity: 1, + source: { + align: 'left', + color: '#000000', + content: 'hi', + fontFamily: 'sans', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + selectedLayerId: 't', + version: 2, + width: 100, + }; + const { store } = createReactiveStore(textLayerDoc); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + engine.tools.setTool('transform'); + // A session opened on the (now hit-testable) text layer. + expect(engine.stores.transformSession.get()).not.toBeNull(); + engine.layers.updateTransformSession({ rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 }); + dispatch.mockClear(); + engine.layers.applyTransform(); + + const actions = dispatch.mock.calls.map((call) => call[0] as EngineTestAction); + const updates = actions.filter((a) => a.type === 'updateCanvasLayer'); + // ONE param transform; source untouched (stays text) — no convert, no bake. + expect(updates).toHaveLength(1); + expect(updates[0]).toEqual({ + id: 't', + patch: { transform: { rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 } }, + type: 'updateCanvasLayer', + }); + expect(actions.some((a) => a.type === 'convertCanvasLayer' || a.type === 'updateCanvasLayerSource')).toBe(false); + expect(engine.stores.transformSession.get()).toBeNull(); + + dispatch.mockClear(); + engine.history.undo(); + const undo = dispatch.mock.calls + .map((call) => call[0] as EngineTestAction) + .find((a) => a.type === 'updateCanvasLayer'); + expect(undo).toEqual({ + id: 't', + patch: { transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 } }, + type: 'updateCanvasLayer', + }); + + engine.lifecycle.dispose(); + }); + + it('handleEscapePriority cancels an open transform session (chain: transform → deselect)', () => { + const { store } = createReactiveStore(selectedImageDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + engine.tools.setTool('transform'); + engine.layers.updateTransformSession({ rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 }); + expect(engine.stores.transformSession.get()).not.toBeNull(); + + engine.tools.handleEscapePriority({ gestureWasActive: false }); + expect(engine.stores.transformSession.get()).toBeNull(); + + engine.lifecycle.dispose(); + }); + + it('an unchanged transform Apply cancels with no dispatch', () => { + const { store } = createReactiveStore(selectedImageDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + engine.tools.setTool('transform'); + dispatch.mockClear(); + engine.layers.applyTransform(); + + expect(dispatch.mock.calls.filter((c) => (c[0] as EngineTestAction).type === 'updateCanvasLayer')).toHaveLength(0); + expect(engine.stores.transformSession.get()).toBeNull(); + expect(engine.stores.canUndo.get()).toBe(false); + + engine.lifecycle.dispose(); + }); + + it('Cancel drops the session with no dispatch', () => { + const { store } = createReactiveStore(selectedImageDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + engine.tools.setTool('transform'); + engine.layers.updateTransformSession({ rotation: 0, scaleX: 3, scaleY: 3, x: 0, y: 0 }); + dispatch.mockClear(); + engine.layers.cancelTransform(); + + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.transformSession.get()).toBeNull(); + + engine.lifecycle.dispose(); + }); + + it('paint layer Apply bakes pixels through the matrix, resets the transform, and composes one undo entry', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + // A paint layer that already carries a non-identity transform, so undo restores + // a transform distinct from the post-bake identity. Content-sized: it needs a + // persisted bitmap so its cache is non-empty (an empty paint layer is not + // transformable). + const movedPaint = paintDoc(); + const movedLayer = movedPaint.layers[0] as CanvasRasterLayerContractV2; + movedLayer.source = { + bitmap: { height: 50, imageName: 'paint1-bmp', width: 50 }, + offset: { x: 0, y: 0 }, + type: 'paint', + }; + movedLayer.transform = { rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 }; + const { store } = createReactiveStore(movedPaint); + const dispatch = store.dispatch as Mock; + const bitmapStore = createSpyBitmapStore(); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); // build the paint layer cache + + const surfacesBeforeApply = surfaces.length; + const dirtyBefore = bitmapStore.markLayerDirty.mock.calls.length; + + engine.tools.setTool('transform'); + engine.layers.updateTransformSession({ rotation: 0, scaleX: 2, scaleY: 2, x: 10, y: 20 }); + dispatch.mockClear(); + engine.layers.applyTransform(); + + // A fresh CONTENT-sized surface (the transformed content bounds, 100×100 here) + // was allocated and drawn through the bake matrix. The 50×50 cache at scale 2 + + // offset (10,20) bakes to bounds {10,20,100,100}, so the surface is 100×100 and + // the bake transform's translation is shifted by the baked origin to (0,0). + const baked = surfaces[surfacesBeforeApply]; + expect(baked).toBeDefined(); + expect(baked!.width).toBe(100); + expect(baked!.height).toBe(100); + const setTransforms = baked!.callLog.filter((entry) => entry.op === 'setTransform'); + // The non-identity setTransform carries the bake matrix scale (a=2, d=2), with + // its translation shifted into baked-local space (e=0, f=0). + expect( + setTransforms.some( + (entry) => entry.args[0] === 2 && entry.args[3] === 2 && entry.args[4] === 0 && entry.args[5] === 0 + ) + ).toBe(true); + expect(baked!.callLog.some((entry) => entry.op === 'drawImage')).toBe(true); + // Determinism: the bake always smooths (a doc-space resample, unlike the + // live composite which varies smoothing with zoom) rather than leaving it + // at whatever the fresh surface's context happens to default to. + expect( + baked!.callLog.some( + (entry) => entry.op === 'set' && entry.args[0] === 'imageSmoothingEnabled' && entry.args[1] === true + ) + ).toBe(true); + + // The reducer transform was reset to identity (pixel-free structural change). + const resetDispatch = dispatch.mock.calls + .map((call) => call[0] as EngineTestAction) + .find((action) => action.type === 'updateCanvasLayer'); + expect(resetDispatch).toEqual({ + id: 'paint1', + patch: { transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 } }, + type: 'updateCanvasLayer', + }); + + // Baked pixels persist through the normal dirty path; session cleared; undoable. + expect(bitmapStore.markLayerDirty.mock.calls.length).toBeGreaterThan(dirtyBefore); + expect(engine.stores.transformSession.get()).toBeNull(); + expect(engine.stores.canUndo.get()).toBe(true); + + // Undo restores BOTH the old transform and the old pixels in one step. + dispatch.mockClear(); + engine.history.undo(); + const undoDispatch = dispatch.mock.calls + .map((call) => call[0] as EngineTestAction) + .find((action) => action.type === 'updateCanvasLayer'); + expect(undoDispatch).toEqual({ + id: 'paint1', + patch: { transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 } }, + type: 'updateCanvasLayer', + }); + expect(engine.stores.canRedo.get()).toBe(true); + + engine.lifecycle.dispose(); + }); + + it('tears down the transform session on a document replace', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { setDocument, store } = createReactiveStore(selectedImageDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + + engine.tools.setTool('transform'); + expect(engine.stores.transformSession.get()).not.toBeNull(); + + // A dims change is a wholesale document replacement. + setDocument({ ...selectedImageDoc(), height: 200, width: 200 }, 1); + + expect(engine.stores.transformSession.get()).toBeNull(); + + engine.lifecycle.dispose(); + }); + + it('cancels the session (and its preview override) when its layer is deleted mid-session', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const doc = selectedImageDoc(); + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + + engine.tools.setTool('transform'); + engine.layers.updateTransformSession({ rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 5 }); + expect(engine.stores.transformSession.get()).not.toBeNull(); + + // Delete the session's layer via an ordinary layer-array edit (same dims, + // same revision) — NOT a wholesale replace, so this exercises the + // `onLayersChanged` teardown rather than `onDocumentReplaced`'s. + setDocument({ ...doc, layers: [] }); + + expect(engine.stores.transformSession.get()).toBeNull(); + + engine.lifecycle.dispose(); + }); + + // ---- temp-tool switch (space/alt hold) must not discard the session ---- + // + // The pointer pipeline flags a modifier-hold switch (and its matching + // restore) with `{ temporary: true }` on `setTool` (see + // `pointerPipeline.test.ts`, "temporary modifier tools"). These tests drive + // that same seam directly against the engine: the public `CanvasEngine` + // type narrows `setTool` to one argument, but the runtime function accepts + // the pipeline's second (`opts`) argument, so the cast below exercises the + // exact call the pipeline makes on a real space/alt hold and release. + describe('temp-tool switch (space/alt hold)', () => { + it('preserves the session and its numeric edits, resuming after the hold ends', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(selectedImageDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + + engine.tools.setTool('transform'); + // A numeric edit, as the options bar would drive. + engine.layers.updateTransformSession({ rotation: 0, scaleX: 2, scaleY: 1, x: 5, y: 0 }); + const edited = { rotation: 0, scaleX: 2, scaleY: 1, x: 5, y: 0 }; + expect(engine.stores.transformSession.get()?.transform).toEqual(edited); + + const setTool = engine.tools.setTool as (id: ToolId, opts?: { temporary?: boolean }) => void; + + setTool('view', { temporary: true }); // space down + expect(engine.stores.activeTool.get()).toBe('view'); + // The session — and the numeric edit — survive the hold. + expect(engine.stores.transformSession.get()?.transform).toEqual(edited); + + setTool('transform', { temporary: true }); // space up: resume + expect(engine.stores.activeTool.get()).toBe('transform'); + // Resuming does not reopen the session from the layer's committed + // transform, discarding the edit. + expect(engine.stores.transformSession.get()?.transform).toEqual(edited); + + engine.lifecycle.dispose(); + }); + + it('a REAL tool switch (not temporary) still cancels the session', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(selectedImageDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + + engine.tools.setTool('transform'); + expect(engine.stores.transformSession.get()).not.toBeNull(); + + engine.tools.setTool('view'); // a real switch — no `{ temporary: true }` + + expect(engine.stores.transformSession.get()).toBeNull(); + + engine.lifecycle.dispose(); + }); + + it('cancels cleanly when the session layer is deleted mid-hold, instead of resurrecting it on resume', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const doc = selectedImageDoc(); + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + + engine.tools.setTool('transform'); + expect(engine.stores.transformSession.get()).not.toBeNull(); + + const setTool = engine.tools.setTool as (id: ToolId, opts?: { temporary?: boolean }) => void; + setTool('view', { temporary: true }); // space down + + // The session's layer is deleted while temp-switched away (e.g. via the + // layers panel) — the layer-change teardown cancels the session + // immediately, regardless of which tool is active. + setDocument({ ...doc, layers: [] }); + expect(engine.stores.transformSession.get()).toBeNull(); + + setTool('transform', { temporary: true }); // space up: resume + // Resuming must not resurrect a session against the now layer-less + // document. + expect(engine.stores.transformSession.get()).toBeNull(); + + engine.lifecycle.dispose(); + }); + }); +}); + +// ---- Selection subsystem ------------------------------------------------ + +const lockedPaintDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'normal', + id: 'paint1', + isEnabled: true, + isLocked: true, + name: 'paint1', + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + selectedLayerId: 'paint1', + version: 2, + width: 100, +}); + +describe('engine selection: select all / deselect / invert + hasSelection store', () => { + it('selectAll sets hasSelection; deselect clears it', () => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const { store } = createFakeStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect(engine.stores.hasSelection.get()).toBe(false); + engine.selection.selectAll(); + expect(engine.stores.hasSelection.get()).toBe(true); + engine.selection.deselect(); + expect(engine.stores.hasSelection.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('invertSelection of an empty selection selects everything', () => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const { store } = createFakeStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + engine.selection.invertSelection(); + expect(engine.stores.hasSelection.get()).toBe(true); + engine.lifecycle.dispose(); + }); +}); + +describe('engine selection: fill / erase', () => { + const makeEngine = (doc: CanvasDocumentContractV2) => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const { store } = createFakeStore(doc); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + return { bitmapStore, engine }; + }; + + it('fills an empty selected paint control without adding a raster layer', () => { + const h = createControlSelectionHarness({ source: { bitmap: null, type: 'paint' } }); + h.engine.selection.selectAll(); + h.engine.selection.fillSelection(); + expect(h.engine.document.getDocument()!.layers).toHaveLength(1); + expect(h.engine.document.getDocument()!.layers[0]).toMatchObject({ id: 'control', type: 'control' }); + expect(h.engine.stores.canUndo.get()).toBe(true); + h.engine.lifecycle.dispose(); + }); + + it('erases an existing selected paint control without adding a raster layer', async () => { + const h = createControlSelectionHarness({ + source: { bitmap: { height: 10, imageName: 'paint-bitmap', width: 10 }, type: 'paint' }, + }); + await h.publishInitialCache(); + h.engine.selection.selectAll(); + h.engine.selection.eraseSelection(); + expect(h.engine.document.getDocument()!.layers).toHaveLength(1); + expect(h.engine.document.getDocument()!.layers[0]).toMatchObject({ id: 'control', type: 'control' }); + expect(h.engine.stores.canUndo.get()).toBe(true); + h.engine.lifecycle.dispose(); + }); + + it.each(['fill', 'erase'] as const)('materializes an image control and %ss it as one undo step', async (kind) => { + const h = createControlSelectionHarness({ + source: { image: { height: 10, imageName: 'control-image', width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 }, + }); + await h.publishInitialCache(); + const before = structuredClone(h.engine.document.getDocument()!.layers[0]); + h.engine.selection.selectAll(); + if (kind === 'fill') { + h.engine.selection.fillSelection(); + } else { + h.engine.selection.eraseSelection(); + } + const after = structuredClone(h.engine.document.getDocument()!.layers[0]); + expect(after).toMatchObject({ id: 'control', source: { type: 'paint' }, type: 'control' }); + h.engine.history.undo(); + expect(h.engine.document.getDocument()!.layers[0]).toEqual(before); + h.engine.history.redo(); + expect(h.engine.document.getDocument()!.layers[0]).toEqual(after); + h.engine.lifecycle.dispose(); + }); + + it('does not materialize an image control when Erase has no overlapping pixels', async () => { + const h = createControlSelectionHarness({ + source: { image: { height: 10, imageName: 'control-image', width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 50, y: 50 }, + }); + await h.publishInitialCache(); + const before = structuredClone(h.engine.document.getDocument()); + h.engine.tools.setTool('lasso'); + h.overlay.fire('pointerdown', pointerAt(0, 0)); + h.overlay.fire('pointermove', pointerAt(10, 0)); + h.overlay.fire('pointermove', pointerAt(10, 10)); + h.overlay.fire('pointermove', pointerAt(0, 10)); + h.overlay.fire('pointerup', pointerAt(0, 0, { buttons: 0 })); + h.engine.selection.eraseSelection(); + expect(h.engine.document.getDocument()).toEqual(before); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.bitmapStore.suspendLayer).not.toHaveBeenCalled(); + h.engine.lifecycle.dispose(); + }); + + it.each([ + ['locked', { isLocked: true }], + ['disabled', { isEnabled: false }], + ] as const)('selection editing leaves a %s control unchanged', (_scenario, patch) => { + const h = createControlSelectionHarness({ source: { bitmap: null, type: 'paint' }, ...patch }); + const before = structuredClone(h.engine.document.getDocument()); + h.engine.selection.selectAll(); + h.engine.selection.fillSelection(); + expect(h.engine.document.getDocument()).toEqual(before); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + h.engine.lifecycle.dispose(); + }); + + it('selection editing leaves a not-ready image control unchanged', () => { + const h = createControlSelectionHarness({ + source: { image: { height: 10, imageName: 'pending-image', width: 10 }, type: 'image' }, + }); + const before = structuredClone(h.engine.document.getDocument()); + h.engine.selection.selectAll(); + h.engine.selection.fillSelection(); + expect(h.engine.document.getDocument()).toEqual(before); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + h.engine.lifecycle.dispose(); + }); + + it.each(['fill', 'erase'] as const)('does not publish a byte-identical direct control %s', async (kind) => { + const h = createControlSelectionHarness({ + source: { bitmap: { height: 10, imageName: 'paint-bitmap', width: 10 }, type: 'paint' }, + }); + await h.publishInitialCache(); + h.engine.tools.setTool('lasso'); + h.overlay.fire('pointerdown', pointerAt(0, 0)); + h.overlay.fire('pointermove', pointerAt(5, 0)); + h.overlay.fire('pointermove', pointerAt(5, 5)); + h.overlay.fire('pointermove', pointerAt(0, 5)); + h.overlay.fire('pointerup', pointerAt(0, 0, { buttons: 0 })); + const beforeDocument = structuredClone(h.engine.document.getDocument()); + const beforeCache = await snapshotLayerCache(h.engine, 'control'); + const beforeThumbnailVersion = h.engine.stores.thumbnailVersion.get('control'); + h.setSelectionPixelWrites(false); + + if (kind === 'fill') { + h.engine.selection.fillSelection(); + } else { + h.engine.selection.eraseSelection(); + } + + expect(h.engine.document.getDocument()).toEqual(beforeDocument); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.bitmapStore.suspendLayer).toHaveBeenCalledOnce(); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.engine.stores.thumbnailVersion.get('control')).toBe(beforeThumbnailVersion); + const restored = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + expect(restored.status).toBe('ok'); + if (restored.status === 'ok') { + expect(restored.surface).toBe(beforeCache.surface); + expect(restored.rect).toEqual(beforeCache.rect); + expect(restored.guard.cacheVersion).toBe(beforeCache.version); + } + + h.setSelectionPixelWrites(true); + if (kind === 'fill') { + h.engine.selection.fillSelection(); + } else { + h.engine.selection.eraseSelection(); + } + expect(h.engine.stores.canUndo.get()).toBe(true); + h.engine.lifecycle.dispose(); + }); + + it('restores direct control cache growth after a byte-identical fill', async () => { + const h = createControlSelectionHarness({ source: { bitmap: null, type: 'paint' } }); + const beforeDocument = structuredClone(h.engine.document.getDocument()); + const beforeExport = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + const beforeThumbnailVersion = h.engine.stores.thumbnailVersion.get('control'); + h.engine.selection.selectAll(); + h.setSelectionPixelWrites(false); + + h.engine.selection.fillSelection(); + + expect(h.engine.document.getDocument()).toEqual(beforeDocument); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.bitmapStore.suspendLayer).toHaveBeenCalledOnce(); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.engine.stores.thumbnailVersion.get('control')).toBe(beforeThumbnailVersion); + const restored = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + expect(restored.status).toBe(beforeExport.status); + + h.setSelectionPixelWrites(true); + h.engine.selection.fillSelection(); + expect(h.engine.stores.canUndo.get()).toBe(true); + h.engine.lifecycle.dispose(); + }); + + it('rolls back a byte-identical materialized control selection edit', async () => { + const h = createControlSelectionHarness({ + source: { image: { height: 10, imageName: 'control-image', width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 }, + }); + await h.publishInitialCache(); + const beforeDocument = structuredClone(h.engine.document.getDocument()); + const beforeCache = await snapshotLayerCache(h.engine, 'control'); + const beforeThumbnailVersion = h.engine.stores.thumbnailVersion.get('control'); + h.engine.selection.selectAll(); + h.setSelectionPixelWrites(false); + + h.engine.selection.fillSelection(); + + expect(h.engine.document.getDocument()).toEqual(beforeDocument); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.bitmapStore.suspendLayer).toHaveBeenCalledOnce(); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.engine.stores.thumbnailVersion.get('control')).toBe(beforeThumbnailVersion); + const restored = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + expect(restored.status).toBe('ok'); + if (restored.status === 'ok') { + expect(restored.surface).toBe(beforeCache.surface); + expect(restored.rect).toEqual(beforeCache.rect); + expect(restored.guard.cacheVersion).toBe(beforeCache.version); + } + + h.setSelectionPixelWrites(true); + h.engine.selection.fillSelection(); + expect(h.engine.stores.canUndo.get()).toBe(true); + expect(h.bitmapStore.suspendLayer).toHaveBeenCalledTimes(2); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledTimes(2); + h.engine.lifecycle.dispose(); + }); + + it('keeps materialized no-effect rollback authoritative when growth restoration throws once', async () => { + const h = createControlSelectionHarness({ + source: { image: { height: 10, imageName: 'control-image', width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 }, + }); + await h.publishInitialCache(); + const beforeDocument = structuredClone(h.engine.document.getDocument()); + const beforeCache = await snapshotLayerCache(h.engine, 'control'); + const beforeThumbnailVersion = h.engine.stores.thumbnailVersion.get('control'); + const createSurface = h.backend.createSurface.bind(h.backend); + let didThrow = false; + vi.spyOn(h.backend, 'createSurface').mockImplementation((width, height) => { + const surface = createSurface(width, height); + const resize = surface.resize.bind(surface); + surface.resize = (nextWidth, nextHeight) => { + if (!didThrow && surface.width === 100 && nextWidth === 20 && nextHeight === 20) { + didThrow = true; + throw new Error('selection rollback restoration failed'); + } + resize(nextWidth, nextHeight); + }; + return surface; + }); + h.engine.selection.selectAll(); + h.setSelectionPixelWrites(false); + + expect(() => h.engine.selection.fillSelection()).toThrow('selection rollback restoration failed'); + + expect(didThrow).toBe(true); + expect(h.engine.document.getDocument()).toEqual(beforeDocument); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.bitmapStore.suspendLayer).toHaveBeenCalledOnce(); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + expect(h.engine.stores.thumbnailVersion.get('control')).toBe(beforeThumbnailVersion); + const restored = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + expect(restored.status).toBe('ok'); + if (restored.status === 'ok') { + expect(restored.surface).toBe(beforeCache.surface); + expect(restored.rect).toEqual(beforeCache.rect); + expect(restored.guard.cacheVersion).toBe(beforeCache.version); + } + + vi.mocked(h.backend.createSurface).mockRestore(); + h.setSelectionPixelWrites(true); + h.engine.selection.fillSelection(); + expect(h.engine.stores.canUndo.get()).toBe(true); + expect(h.bitmapStore.suspendLayer).toHaveBeenCalledTimes(2); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledTimes(2); + h.engine.lifecycle.dispose(); + }); + + it('rolls back a direct control selection edit when masked compositing fails', async () => { + const h = createControlSelectionHarness({ + source: { bitmap: { height: 10, imageName: 'paint-bitmap', width: 10 }, type: 'paint' }, + }); + await h.publishInitialCache(); + const beforeDocument = structuredClone(h.engine.document.getDocument()); + const beforeCache = await snapshotLayerCache(h.engine, 'control'); + const originalCtx = beforeCache.surface.ctx; + const capturedReads: ImageData[] = []; + const failingCtx = new Proxy(originalCtx, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (property === 'getImageData' && typeof value === 'function') { + return (...args: unknown[]) => { + const result = Reflect.apply(value, target, args) as ImageData; + capturedReads.push(result); + return result; + }; + } + if (property === 'drawImage' && typeof value === 'function') { + return (...args: unknown[]) => { + Reflect.apply(value, target, args); + throw new Error('selection compositing failed'); + }; + } + return value; + }, + }); + Object.defineProperty(beforeCache.surface, 'ctx', { configurable: true, value: failingCtx }); + h.engine.tools.setTool('lasso'); + h.overlay.fire('pointerdown', pointerAt(0, 0)); + h.overlay.fire('pointermove', pointerAt(5, 0)); + h.overlay.fire('pointermove', pointerAt(5, 5)); + h.overlay.fire('pointermove', pointerAt(0, 5)); + h.overlay.fire('pointerup', pointerAt(0, 0, { buttons: 0 })); + + expect(() => h.engine.selection.fillSelection()).toThrow('selection compositing failed'); + + expect(h.engine.document.getDocument()).toEqual(beforeDocument); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(capturedReads[0]).toMatchObject({ height: 10, width: 10 }); + expect(capturedReads.at(-1)).toMatchObject({ height: 5, width: 5 }); + expect( + (beforeCache.surface as StubRasterSurface).callLog.filter((entry) => entry.op === 'putImageData').at(-1)?.args[0] + ).toBe(capturedReads[0]); + + Object.defineProperty(beforeCache.surface, 'ctx', { configurable: true, value: originalCtx }); + h.engine.selection.fillSelection(); + expect(h.engine.stores.canUndo.get()).toBe(true); + h.engine.lifecycle.dispose(); + }); + + it('rolls back a materialized control selection edit when masked compositing fails', async () => { + const h = createControlSelectionHarness({ + source: { image: { height: 10, imageName: 'control-image', width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 }, + }); + await h.publishInitialCache(); + const beforeDocument = structuredClone(h.engine.document.getDocument()); + const beforeCache = await snapshotLayerCache(h.engine, 'control'); + const createSurface = h.backend.createSurface.bind(h.backend); + vi.spyOn(h.backend, 'createSurface').mockImplementation((width, height) => { + const surface = createSurface(width, height); + const originalCtx = surface.ctx; + const failingCtx = new Proxy(originalCtx, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (property !== 'drawImage' || typeof value !== 'function') { + return value; + } + return (...args: unknown[]) => { + const result = Reflect.apply(value, target, args); + if (target.globalCompositeOperation === 'destination-out') { + throw new Error('selection compositing failed'); + } + return result; + }; + }, + }); + Object.defineProperty(surface, 'ctx', { configurable: true, value: failingCtx }); + return surface; + }); + h.engine.selection.selectAll(); + + expect(() => h.engine.selection.eraseSelection()).toThrow('selection compositing failed'); + + expect(h.engine.document.getDocument()).toEqual(beforeDocument); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + const restored = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + expect(restored.status).toBe('ok'); + if (restored.status === 'ok') { + expect(restored.surface).toBe(beforeCache.surface); + expect(restored.rect).toEqual(beforeCache.rect); + expect(restored.guard.cacheVersion).toBe(beforeCache.version); + } + + vi.mocked(h.backend.createSurface).mockRestore(); + h.engine.selection.eraseSelection(); + expect(h.engine.stores.canUndo.get()).toBe(true); + h.engine.lifecycle.dispose(); + }); + + it('preserves transaction-owned rollback when a materialized control selection commit fails', async () => { + const h = createControlSelectionHarness({ + source: { image: { height: 10, imageName: 'control-image', width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 }, + }); + await h.publishInitialCache(); + const beforeDocument = structuredClone(h.engine.document.getDocument()); + const beforeCache = await snapshotLayerCache(h.engine, 'control'); + h.engine.selection.selectAll(); + historyPreparationFaults.layerSnapshot = true; + + expect(() => h.engine.selection.fillSelection()).toThrow('layer snapshot preparation failed'); + + expect(h.engine.document.getDocument()).toEqual(beforeDocument); + expect(h.engine.stores.canUndo.get()).toBe(false); + expect(h.bitmapStore.markLayerDirty).not.toHaveBeenCalled(); + expect(h.bitmapStore.releaseSuspendedLayer).toHaveBeenCalledOnce(); + const restored = await h.engine.exports.exportLayerPixels('control', { includeDisabled: true }); + expect(restored.status).toBe('ok'); + if (restored.status === 'ok') { + expect(restored.surface).toBe(beforeCache.surface); + expect(restored.rect).toEqual(beforeCache.rect); + expect(restored.guard.cacheVersion).toBe(beforeCache.version); + } + h.engine.lifecycle.dispose(); + }); + + it('fillSelection on the selected paint layer records one undoable edit + persists', () => { + const { bitmapStore, engine } = makeEngine(paintDoc()); + engine.selection.selectAll(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.selection.fillSelection(); + expect(engine.stores.canUndo.get()).toBe(true); + expect(bitmapStore.markLayerDirty).toHaveBeenCalledWith('paint1'); + // Undo restores (canRedo becomes available). + engine.history.undo(); + expect(engine.stores.canRedo.get()).toBe(true); + engine.lifecycle.dispose(); + }); + + it('blocks paint without closing a guarded operation', async () => { + const { engine } = makeEngine(paintDoc()); + engine.selection.selectAll(); + engine.selection.fillSelection(); + const exported = await engine.exports.exportLayerPixels('paint1'); + if (exported.status !== 'ok') { + throw new Error('expected published paint pixels'); + } + const cleanupPreview = vi.fn(); + getCanvasOperations(engine).controller.start({ + cleanupPreview, + guard: exported.guard, + identity: { kind: 'filter', layerId: 'paint1', projectId: 'p1' }, + }); + + engine.selection.fillSelection(); + + expect(cleanupPreview).not.toHaveBeenCalled(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toMatchObject({ + identity: { kind: 'filter' }, + status: 'active', + }); + engine.lifecycle.dispose(); + }); + + it('cache clear cancels a guarded operation and cleans its preview', async () => { + const { engine } = makeEngine(paintDoc()); + engine.selection.selectAll(); + engine.selection.fillSelection(); + const exported = await engine.exports.exportLayerPixels('paint1'); + if (exported.status !== 'ok') { + throw new Error('expected published paint pixels'); + } + const cleanupPreview = vi.fn(); + getCanvasOperations(engine).controller.start({ + cleanupPreview, + guard: exported.guard, + identity: { kind: 'filter', layerId: 'paint1', projectId: 'p1' }, + }); + + await engine.diagnostics.clearCaches(); + + expect(cleanupPreview).toHaveBeenCalledOnce(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + engine.lifecycle.dispose(); + }); + + it('eraseSelection records one undoable edit (on existing content)', () => { + const { engine } = makeEngine(paintDoc()); + engine.selection.selectAll(); + // Content-sized: erase only affects EXISTING pixels, so give the layer content + // first (a fill grows the empty paint cache to the selection). Then erase records + // its own edit within that extent. + engine.selection.fillSelection(); + engine.selection.eraseSelection(); + expect(engine.stores.canUndo.get()).toBe(true); + engine.lifecycle.dispose(); + }); + + it('eraseSelection is a no-op on an EMPTY paint layer (no pixels to erase)', () => { + const { engine } = makeEngine(paintDoc()); + engine.selection.selectAll(); + engine.selection.eraseSelection(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('fillSelection is a no-op with no selection', () => { + const { engine } = makeEngine(paintDoc()); + engine.selection.fillSelection(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('fillSelection is a no-op on an image-source layer (deferred to rasterize task)', () => { + const { engine } = makeEngine(imageSelectedDoc()); + engine.selection.selectAll(); + engine.selection.fillSelection(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('fillSelection is a no-op on a transparency-locked EMPTY layer (source-atop clamps to existing pixels)', () => { + const doc = paintDoc(); + const target = doc.layers[0]; + if (target?.type === 'raster') { + target.isTransparencyLocked = true; + } + const { engine } = makeEngine(doc); + engine.selection.selectAll(); + engine.selection.fillSelection(); + // The layer has no existing pixels, so a transparency-locked (source-atop) fill + // lands nothing — no undoable edit. + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('fillSelection is a no-op on a locked paint layer', () => { + const { engine } = makeEngine(lockedPaintDoc()); + engine.selection.selectAll(); + engine.selection.fillSelection(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.lifecycle.dispose(); + }); + + it('clearCaches flushes pending bitmap uploads before invalidating (unflushed strokes survive)', async () => { + const { bitmapStore, engine } = makeEngine(paintDoc()); + // The debug "Clear caches" action must persist any in-flight (debounced) paint + // upload before it drops the layer caches — otherwise an unflushed stroke is lost. + await engine.diagnostics.clearCaches(); + expect(bitmapStore.flushPendingUploads).toHaveBeenCalledTimes(1); + engine.lifecycle.dispose(); + }); +}); + +describe('engine selection: marching ants animation + overlay-only', () => { + it('a selection redraws the overlay with ants and never composites the document', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + screen.surface.callLog.length = 0; + overlay.surface.callLog.length = 0; + + engine.selection.selectAll(); + raf.flush(); + + // Overlay redrawn (ants stroked); the screen composite never ran. + const compositeOps = screen.surface.callLog.filter( + (e) => e.op === 'drawImage' || e.op === 'clearRect' || e.op === 'fillRect' + ); + expect(compositeOps).toHaveLength(0); + expect(overlay.surface.callLog.some((e) => e.op === 'stroke')).toBe(true); + + engine.lifecycle.dispose(); + }); + + it('stops the ants loop (no pending frames) on deselect and on detach', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + + // With a selection the loop keeps rescheduling itself frame after frame. + engine.selection.selectAll(); + raf.flush(); + raf.flush(); + expect(raf.pendingCount()).toBeGreaterThan(0); + + // Deselect stops it: after draining, nothing reschedules. + engine.selection.deselect(); + raf.flush(); + expect(raf.pendingCount()).toBe(0); + + // Re-select, then detach: the loop must not leak a pending frame. + engine.selection.selectAll(); + raf.flush(); + expect(raf.pendingCount()).toBeGreaterThan(0); + engine.surface.detach(); + expect(raf.pendingCount()).toBe(0); + + engine.lifecycle.dispose(); + }); +}); + +describe('engine selection: lasso commit through the pipeline', () => { + it('a lasso drag commits a selection (hasSelection true) without dispatching', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + engine.tools.setTool('lasso'); + raf.flush(); + dispatch.mockClear(); + + overlay.fire('pointerdown', pointerAt(10, 10)); + overlay.fire('pointermove', pointerAt(40, 10)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(10, 40, { buttons: 0 })); + + expect(engine.stores.hasSelection.get()).toBe(true); + // Selection is transient: no reducer traffic from the lasso gesture. + expect(dispatch).not.toHaveBeenCalled(); + + engine.lifecycle.dispose(); + }); +}); + +describe('engine selection: document replace clears the selection', () => { + it('a wholesale document swap deselects', () => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const { setDocument, store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + engine.selection.selectAll(); + expect(engine.stores.hasSelection.get()).toBe(true); + // A new document revision replaces the mirror. + setDocument(paintDoc(), 1); + expect(engine.stores.hasSelection.get()).toBe(false); + engine.lifecycle.dispose(); + }); +}); + +// ---- text edit session: create/edit commit, cancel, teardown --------------- + +describe('text edit session', () => { + const textSource = (over: Partial> = {}) => ({ + align: 'left' as const, + color: '#112233', + content: 'hello', + fontFamily: 'Inter', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.2, + type: 'text' as const, + ...over, + }); + + const textDoc = (over: Partial = {}): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'normal', + id: 'txt1', + isEnabled: true, + isLocked: false, + name: 'Text 1', + opacity: 1, + source: textSource(), + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 5, y: 6 }, + type: 'raster', + }, + ], + selectedLayerId: 'txt1', + version: 2, + width: 100, + ...over, + }); + + const makeEngine = (doc: CanvasDocumentContractV2) => { + const { setDocument, store } = createReactiveStore(doc); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const layerActions = () => dispatch.mock.calls.map((call) => call[0] as EngineTestAction); + return { dispatch, engine, layerActions, setDocument }; + }; + + it('create-mode commit dispatches ONE addCanvasLayer with the typed content; undo removes it', () => { + const { dispatch, engine, layerActions } = makeEngine(paintDoc()); + engine.tools.setTool('text'); + engine.layers.openTextCreate({ x: 10, y: 20 }); + expect(engine.stores.textEditSession.get()?.mode).toBe('create'); + + dispatch.mockClear(); + engine.layers.commitTextEdit('Typed here'); + + const adds = layerActions().filter((a) => a.type === 'addCanvasLayer'); + expect(adds).toHaveLength(1); + const forward = adds[0]; + if (forward?.type === 'addCanvasLayer' && forward.layer.type === 'raster' && forward.layer.source.type === 'text') { + expect(forward.layer.source.content).toBe('Typed here'); + expect(forward.layer.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 }); + } else { + throw new Error('expected an addCanvasLayer with a text source'); + } + expect(engine.stores.textEditSession.get()).toBeNull(); + + dispatch.mockClear(); + engine.history.undo(); + const removes = layerActions().filter((a) => a.type === 'removeCanvasLayers'); + expect(removes).toHaveLength(1); + engine.lifecycle.dispose(); + }); + + it('an empty create-mode commit dispatches nothing (cancel semantics)', () => { + const { dispatch, engine } = makeEngine(paintDoc()); + engine.tools.setTool('text'); + engine.layers.openTextCreate({ x: 0, y: 0 }); + dispatch.mockClear(); + engine.layers.commitTextEdit(' '); + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.lifecycle.dispose(); + }); + + it('edit-mode commit dispatches ONE updateCanvasLayerSource with the exact inverse', () => { + const { dispatch, engine, layerActions } = makeEngine(textDoc()); + engine.tools.setTool('text'); + engine.layers.openTextEdit('txt1'); + expect(engine.stores.textEditSession.get()?.mode).toBe('edit'); + + dispatch.mockClear(); + engine.layers.commitTextEdit('changed'); + + const edits = layerActions().filter((a) => a.type === 'updateCanvasLayerSource'); + expect(edits).toHaveLength(1); + const forward = edits[0]; + if (forward?.type === 'updateCanvasLayerSource' && forward.source.type === 'text') { + expect(forward.id).toBe('txt1'); + expect(forward.source.content).toBe('changed'); + } else { + throw new Error('expected an updateCanvasLayerSource text edit'); + } + + dispatch.mockClear(); + engine.history.undo(); + const inverse = layerActions().find((a) => a.type === 'updateCanvasLayerSource'); + if (inverse?.type === 'updateCanvasLayerSource' && inverse.source.type === 'text') { + expect(inverse.source.content).toBe('hello'); + } else { + throw new Error('expected the inverse to restore the original content'); + } + engine.lifecycle.dispose(); + }); + + it('folds a live style change into the single edit commit', () => { + const { dispatch, engine, layerActions } = makeEngine(textDoc()); + engine.tools.setTool('text'); + engine.layers.openTextEdit('txt1'); + engine.layers.updateTextEditStyle({ color: '#ff0000', fontSize: 40 }); + + dispatch.mockClear(); + engine.layers.commitTextEdit('hello'); + + const edits = layerActions().filter((a) => a.type === 'updateCanvasLayerSource'); + expect(edits).toHaveLength(1); + const forward = edits[0]; + if (forward?.type === 'updateCanvasLayerSource' && forward.source.type === 'text') { + expect(forward.source.color).toBe('#ff0000'); + expect(forward.source.fontSize).toBe(40); + expect(forward.source.content).toBe('hello'); + } else { + throw new Error('expected the folded style change'); + } + engine.lifecycle.dispose(); + }); + + it('an unchanged edit-mode commit dispatches nothing', () => { + const { dispatch, engine } = makeEngine(textDoc()); + engine.tools.setTool('text'); + engine.layers.openTextEdit('txt1'); + dispatch.mockClear(); + engine.layers.commitTextEdit('hello'); + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.lifecycle.dispose(); + }); + + it('cancel drops the session with no dispatch', () => { + const { dispatch, engine } = makeEngine(textDoc()); + engine.tools.setTool('text'); + engine.layers.openTextEdit('txt1'); + dispatch.mockClear(); + engine.layers.cancelTextEdit(); + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.lifecycle.dispose(); + }); + + it('does not open an edit session on a locked text layer', () => { + const { engine } = makeEngine( + textDoc({ + layers: [ + { + blendMode: 'normal', + id: 'txt1', + isEnabled: true, + isLocked: true, + name: 'Text 1', + opacity: 1, + source: textSource(), + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + }) + ); + engine.tools.setTool('text'); + engine.layers.openTextEdit('txt1'); + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.lifecycle.dispose(); + }); + + it('a temporary tool switch (space-hold) preserves the open session', () => { + const { engine } = makeEngine(textDoc()); + engine.tools.setTool('text'); + engine.layers.openTextEdit('txt1'); + expect(engine.stores.textEditSession.get()).not.toBeNull(); + + // Space-hold switches to the view tool temporarily; the session must survive. + (engine.tools.setTool as (id: ToolId, opts?: { temporary?: boolean }) => void)('view', { temporary: true }); + expect(engine.stores.textEditSession.get()).not.toBeNull(); + engine.lifecycle.dispose(); + }); + + it('a real tool switch away from the text tool tears the session down', () => { + const { engine } = makeEngine(textDoc()); + engine.tools.setTool('text'); + engine.layers.openTextEdit('txt1'); + expect(engine.stores.textEditSession.get()).not.toBeNull(); + + engine.tools.setTool('brush'); + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.lifecycle.dispose(); + }); + + it('drops the session on a wholesale document replace', () => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const { engine, setDocument } = makeEngine(textDoc()); + engine.tools.setTool('text'); + engine.layers.openTextEdit('txt1'); + expect(engine.stores.textEditSession.get()).not.toBeNull(); + setDocument(paintDoc(), 1); + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.lifecycle.dispose(); + }); + + it('drops the session on dispose', () => { + const { engine } = makeEngine(paintDoc()); + engine.tools.setTool('text'); + engine.layers.openTextCreate({ x: 0, y: 0 }); + expect(engine.stores.textEditSession.get()).not.toBeNull(); + engine.lifecycle.dispose(); + expect(engine.stores.textEditSession.get()).toBeNull(); + }); + + // ---- click-elsewhere-to-commit (pointerdown / Escape) ---- + // + // Regression: `commitTextEdit` only fired on the portal's blur, but the pointer + // pipeline `preventDefault`s a canvas pointerdown (suppressing that blur), and + // the mid-gesture guard swallowed any blur that did land. The commit now runs + // engine-side on pointerdown (before a gesture starts), reading the live portal + // content via a registered reader; Escape cancels a defocused session via the + // engine's escape ladder. These drive that engine-side logic directly. + + it('commitOpenTextSession reads the registered content reader and commits the create', () => { + const { engine, layerActions } = makeEngine(paintDoc()); + engine.tools.setTool('text'); + engine.layers.openTextCreate({ x: 10, y: 20 }); + engine.layers.setTextEditContentReader(() => 'live typed text'); + + expect(engine.layers.commitOpenTextSession()).toBe(true); + + const adds = layerActions().filter((a) => a.type === 'addCanvasLayer'); + expect(adds).toHaveLength(1); + const add = adds[0]; + if (add?.type === 'addCanvasLayer' && add.layer.type === 'raster' && add.layer.source.type === 'text') { + expect(add.layer.source.content).toBe('live typed text'); + } else { + throw new Error('expected an addCanvasLayer with the live reader content'); + } + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.lifecycle.dispose(); + }); + + it('commitOpenTextSession returns false when no session is open', () => { + const { dispatch, engine } = makeEngine(paintDoc()); + expect(engine.layers.commitOpenTextSession()).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + engine.lifecycle.dispose(); + }); + + it('commitOpenTextSession falls back to the session content when no reader is registered', () => { + const { dispatch, engine } = makeEngine(textDoc()); + engine.tools.setTool('text'); + engine.layers.openTextEdit('txt1'); // session content = 'hello' + dispatch.mockClear(); + + expect(engine.layers.commitOpenTextSession()).toBe(true); + // No reader → uses the session's own content ('hello') → unchanged edit → no dispatch. + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.lifecycle.dispose(); + }); + + it('a second commit after the session closed is a no-op (one commit per close)', () => { + const { dispatch, engine, layerActions } = makeEngine(paintDoc()); + engine.tools.setTool('text'); + engine.layers.openTextCreate({ x: 0, y: 0 }); + engine.layers.setTextEditContentReader(() => 'once'); + expect(engine.layers.commitOpenTextSession()).toBe(true); + dispatch.mockClear(); + // The portal's onBlur would re-fire commitTextEdit after the pointerdown commit; + // the session is already null, so it dispatches nothing. + engine.layers.commitTextEdit('once'); + expect(engine.layers.commitOpenTextSession()).toBe(false); + expect(layerActions().filter((a) => a.type === 'addCanvasLayer')).toHaveLength(0); + engine.lifecycle.dispose(); + }); + + it('handleEscapePriority cancels a defocused-but-open text session (no dispatch)', () => { + const { dispatch, engine } = makeEngine(textDoc()); + engine.tools.setTool('text'); + engine.layers.openTextEdit('txt1'); + dispatch.mockClear(); + + engine.tools.handleEscapePriority({ gestureWasActive: false }); + expect(engine.stores.textEditSession.get()).toBeNull(); + expect(dispatch).not.toHaveBeenCalled(); + engine.lifecycle.dispose(); + }); +}); + +describe('contextMenuLayerIdAt (canvas right-click target)', () => { + const controlLayer = (id: string): CanvasLayerContract => ({ + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height: 10, imageName: id, width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: false, + }); + + // A raster at index 0 with a control layer above it, both covering [0,10]². At + // the default viewport (zoom 1, no pan) a screen point maps 1:1 to document space. + const stackedDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [rasterLayer('raster'), controlLayer('control')], + selectedLayerId: null, + version: 2, + width: 100, + }); + + const makeEngine = (doc: CanvasDocumentContractV2) => { + const { store } = createFakeStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + return engine; + }; + + it('returns the composite-top layer at the point (control over raster, batch finding N1)', () => { + const engine = makeEngine(stackedDoc()); + // The raster is earlier in the array but the control composites above it. + expect(engine.tools.contextMenuLayerIdAt({ x: 5, y: 5 })).toBe('control'); + engine.lifecycle.dispose(); + }); + + it('returns null on empty space', () => { + const engine = makeEngine(stackedDoc()); + expect(engine.tools.contextMenuLayerIdAt({ x: 60, y: 60 })).toBeNull(); + engine.lifecycle.dispose(); + }); + + it('returns null while a text-edit session is open (never opens over an in-progress edit)', () => { + const engine = makeEngine(stackedDoc()); + engine.tools.setTool('text'); + engine.layers.openTextCreate({ x: 5, y: 5 }); + expect(engine.stores.textEditSession.get()).not.toBeNull(); + expect(engine.tools.contextMenuLayerIdAt({ x: 5, y: 5 })).toBeNull(); + engine.lifecycle.dispose(); + }); +}); + +describe('Select Object canvas engine integration', () => { + const samLayer = (id: string, x = 0): CanvasRasterLayerContractV2 => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { fill: '#fff', height: 100, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 100 }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x, y: 0 }, + type: 'raster', + }); + + /** A raster layer whose baked world rect exactly covers `rect`. */ + const samRectLayer = (id: string, rect: CanvasDocumentContractV2['bbox']): CanvasRasterLayerContractV2 => ({ + ...samLayer(id), + source: { + fill: '#fff', + height: rect.height, + kind: 'rect', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: rect.width, + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: rect.x, y: rect.y }, + }); + + const samDocument = (layers: CanvasLayerContract[] = [samLayer('source')]): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId: layers[0]?.id ?? null, + version: 2, + width: 100, + }); + + const createOpaqueSamBackend = () => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend: StubRasterBackend = { + ...base, + createSurface: (width, height) => { + const surface = base.createSurface(width, height); + surfaces.push(surface); + Object.defineProperty(surface.ctx, 'getImageData', { + value: (_x: number, _y: number, readWidth: number, readHeight: number) => { + const data = new Uint8ClampedArray(readWidth * readHeight * 4); + for (let index = 0; index < data.length; index += 4) { + data[index] = 17; + data[index + 1] = 34; + data[index + 2] = 51; + data[index + 3] = 255; + } + return { colorSpace: 'srgb', data, height: readHeight, width: readWidth } as ImageData; + }, + }); + return surface; + }, + }; + return { backend, surfaces }; + }; + + const withSamPreviewBitmapDimensions = ( + backend: StubRasterBackend, + width = 100, + height = 100 + ): StubRasterBackend => ({ + ...backend, + createImageBitmap: async (source) => { + const bitmap = await backend.createImageBitmap(source); + return Object.assign(bitmap, { height, width }); + }, + }); + + const createSamHarness = async ( + options: { + backend?: StubRasterBackend; + bbox?: CanvasDocumentContractV2['bbox']; + imageResolver?: (imageName: string, signal?: AbortSignal) => Promise; + layers?: CanvasLayerContract[]; + runGraph?: NonNullable['runGraph']; + uploadIntermediate?: NonNullable['uploadIntermediate']; + } = {} + ) => { + const raf = createControllableRaf(); + const windowListeners = new Map void>>(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('addEventListener', (type: string, listener: (event: Event) => void) => { + const listeners = windowListeners.get(type) ?? new Set(); + listeners.add(listener); + windowListeners.set(type, listeners); + }); + vi.stubGlobal('removeEventListener', (type: string, listener: (event: Event) => void) => { + windowListeners.get(type)?.delete(listener); + }); + const bbox = options.bbox ?? { height: 100, width: 100, x: 0, y: 0 }; + const reactive = createReactiveStore({ ...samDocument(options.layers), bbox }); + const engine = createCanvasEngine({ + backend: options.backend ?? { + ...createTestStubRasterBackend(), + createImageBitmap: () => + Promise.resolve({ close: () => undefined, height: bbox.height, width: bbox.width } as unknown as ImageBitmap), + }, + bitmapStore: createSpyBitmapStore(), + imageResolver: options.imageResolver ?? (() => Promise.resolve(new Blob())), + projectId: 'p1', + selectObjectDeps: { + runGraph: + options.runGraph ?? + (() => + Promise.resolve({ height: bbox.height, imageName: 'sam-mask.png', origin: 'test', width: bbox.width })), + uploadIntermediate: + options.uploadIntermediate ?? + (() => Promise.resolve({ height: bbox.height, imageName: 'sam-source.png', width: bbox.width })), + }, + store: reactive.store, + }); + const screen = createInputCanvas(); + const overlay = createInputCanvas(); + engine.surface.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + const fireKey = (type: 'keydown' | 'keyup', code: string, key: string): void => { + for (const listener of windowListeners.get(type) ?? []) { + listener({ code, key, preventDefault: vi.fn(), repeat: false, target: null } as unknown as Event); + } + }; + return { ...reactive, engine, fireKey, overlay, raf, screen }; + }; + + it('keeps Select Object visible and inputs intact when the first Process fails to upload, then retries', async () => { + const bbox = { height: 100, width: 100, x: 0, y: 0 }; + const reactive = createReactiveStore(samDocument()); + const runGraph = vi.fn(() => + Promise.resolve({ height: bbox.height, imageName: 'sam-mask.png', origin: 'test', width: bbox.width }) + ); + const uploadIntermediate = vi + .fn['uploadIntermediate']>() + .mockRejectedValueOnce(new Error('upload failed')) + .mockResolvedValueOnce({ height: bbox.height, imageName: 'sam-source.png', width: bbox.width }); + const engine = createCanvasEngine({ + backend: { + ...createTestStubRasterBackend(), + createImageBitmap: () => + Promise.resolve({ close: () => undefined, height: bbox.height, width: bbox.width } as unknown as ImageBitmap), + }, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + selectObjectDeps: { + runGraph, + uploadIntermediate, + }, + store: reactive.store, + }); + await engine.exports.exportLayerPixels('source'); + const input = { prompt: 'keep this', type: 'prompt' as const }; + expect(getCanvasOperations(engine).startSelectObject('source')).toBe('started'); + getCanvasOperations(engine).updateSelectObjectSession({ input, invert: true }); + + await expect(getCanvasOperations(engine).processSelectObjectSession()).resolves.toBe('error'); + + expect(runGraph).not.toHaveBeenCalled(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toMatchObject({ phase: 'error', status: 'active' }); + expect(getCanvasOperations(engine).stores.samSession.get()).toMatchObject({ + hasPreview: false, + input, + invert: true, + }); + + await expect(getCanvasOperations(engine).processSelectObjectSession()).resolves.toBe('published'); + expect(runGraph).toHaveBeenCalledOnce(); + expect(getCanvasOperations(engine).stores.samSession.get()).toMatchObject({ + hasPreview: true, + input, + invert: true, + status: 'ready', + }); + engine.lifecycle.dispose(); + }); + + it.each(['document', 'project'] as const)( + 'fully clears Select Object session, store, operation, and tool on %s invalidation', + async (kind) => { + const h = await createSamHarness(); + expect(getCanvasOperations(h.engine).startSelectObject('source')).toBe('started'); + const document = h.engine.document.getDocument()!; + + if (kind === 'document') { + h.setDocument({ ...document }, 1); + } else { + h.setActiveProjectId('p2'); + } + + if (kind === 'project') { + expect(getCanvasOperations(h.engine).stores.samSession.get()).not.toBeNull(); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toMatchObject({ status: 'active' }); + expect(h.engine.stores.activeTool.get()).toBe('sam'); + } else { + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + expect(h.engine.stores.activeTool.get()).toBe('view'); + } + h.engine.lifecycle.dispose(); + } + ); + + const createCommittingSamHarness = async ( + imageResolver: (imageName: string, signal?: AbortSignal) => Promise = () => Promise.resolve(new Blob()), + backend: StubRasterBackend = createTestStubRasterBackend(), + bbox: CanvasDocumentContractV2['bbox'] = { height: 100, width: 100, x: 0, y: 0 } + ) => { + const document = { ...samDocument([samRectLayer('source', bbox)]), bbox }; + const reactive = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: { + ...backend, + createImageBitmap: () => + Promise.resolve({ close: () => undefined, height: bbox.height, width: bbox.width } as unknown as ImageBitmap), + }, + bitmapStore: createSpyBitmapStore(), + imageResolver, + projectId: reactive.projectId, + selectObjectDeps: { + runGraph: () => + Promise.resolve({ height: bbox.height, imageName: 'sam-mask.png', origin: 'test', width: bbox.width }), + uploadIntermediate: () => + Promise.resolve({ height: bbox.height, imageName: 'sam-source.png', width: bbox.width }), + }, + store: reactive.store, + }); + await engine.exports.exportLayerPixels('source'); + getCanvasOperations(engine).startSelectObject('source'); + getCanvasOperations(engine).updateSelectObjectSession({ + input: { + bbox: null, + excludePoints: [], + includePoints: [{ x: bbox.x + 5, y: bbox.y + 5 }], + type: 'visual', + }, + }); + await getCanvasOperations(engine).processSelectObjectSession(); + return { ...reactive, document, engine }; + }; + + const invalidateSelectObjectCommit = async ( + engine: CanvasEngine, + kind: 'input' | 'reset' | 'process' + ): Promise => { + if (kind === 'input') { + getCanvasOperations(engine).updateSelectObjectSession({ input: { prompt: 'new input', type: 'prompt' } }); + } else if (kind === 'reset') { + getCanvasOperations(engine).resetSelectObjectSession(); + } else { + await getCanvasOperations(engine).processSelectObjectSession(); + } + }; + + it('starts on the selected supported source, activates the real sam tool, and edits overlay-only', async () => { + const h = await createSamHarness(); + + expect(getCanvasOperations(h.engine).startSelectObject('source')).toBe('started'); + expect(h.engine.stores.activeTool.get()).toBe('sam'); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toMatchObject({ + identity: { kind: 'select-object', layerId: 'source', projectId: 'p1' }, + status: 'active', + }); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ + input: { bbox: null, excludePoints: [], includePoints: [], type: 'visual' }, + layerName: 'source', + layerType: 'raster', + sourceRect: { height: 100, width: 100, x: 0, y: 0 }, + }); + h.raf.flush(); + h.screen.surface.callLog.length = 0; + h.overlay.surface.callLog.length = 0; + + h.overlay.fire('pointerdown', pointerAt(5, 5)); + h.overlay.fire('pointerup', pointerAt(5, 5, { buttons: 0 })); + h.raf.flush(); + + expect(getCanvasOperations(h.engine).stores.samSession.get()?.input.includePoints).toEqual([{ x: 5, y: 5 }]); + expect(h.overlay.surface.callLog.some((entry) => entry.op === 'arc')).toBe(true); + expect(h.screen.surface.callLog.some((entry) => entry.op === 'clearRect' || entry.op === 'drawImage')).toBe(false); + h.engine.lifecycle.dispose(); + }); + + it('resets edited inputs and point mode immediately after launch without requiring a preview guard', async () => { + const h = await createSamHarness(); + expect(getCanvasOperations(h.engine).startSelectObject('source')).toBe('started'); + getCanvasOperations(h.engine).updateSelectObjectSession({ + input: { + bbox: { height: 10, width: 10, x: 1, y: 2 }, + excludePoints: [{ x: 4, y: 5 }], + includePoints: [{ x: 2, y: 3 }], + type: 'visual', + }, + invert: true, + pointLabel: 'exclude', + }); + + expect(getCanvasOperations(h.engine).resetSelectObjectSession()).toBe('updated'); + + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ + error: null, + hasPreview: false, + input: { bbox: null, excludePoints: [], includePoints: [], type: 'visual' }, + invert: false, + pointLabel: 'include', + status: 'ready', + }); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toMatchObject({ status: 'active' }); + h.engine.lifecycle.dispose(); + }); + + it('resets after a first failed Process while preserving lifecycle ownership', async () => { + const bbox = { height: 100, width: 100, x: 0, y: 0 }; + const reactive = createReactiveStore(samDocument()); + const engine = createCanvasEngine({ + backend: withSamPreviewBitmapDimensions(createTestStubRasterBackend()), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + selectObjectDeps: { + runGraph: () => Promise.reject(new Error('queue failed')), + uploadIntermediate: () => Promise.resolve({ height: 100, imageName: 'source.png', width: 100 }), + }, + store: reactive.store, + }); + await engine.exports.exportLayerPixels('source'); + expect(getCanvasOperations(engine).startSelectObject('source')).toBe('started'); + getCanvasOperations(engine).updateSelectObjectSession({ input: { prompt: 'cat', type: 'prompt' }, invert: true }); + await expect(getCanvasOperations(engine).processSelectObjectSession()).resolves.toBe('error'); + expect(getCanvasOperations(engine).stores.samSession.get()).toMatchObject({ + error: { code: 'queue' }, + status: 'error', + }); + + expect(getCanvasOperations(engine).resetSelectObjectSession()).toBe('updated'); + + expect(getCanvasOperations(engine).stores.samSession.get()).toMatchObject({ + error: null, + hasPreview: false, + input: { bbox: null, excludePoints: [], includePoints: [], type: 'visual' }, + invert: false, + pointLabel: 'include', + status: 'ready', + }); + expect(getCanvasOperations(engine).controller.getSnapshot()).toMatchObject({ status: 'active' }); + expect(engine.document.getDocument()?.bbox).toEqual(bbox); + engine.lifecycle.dispose(); + }); + + it('reuses one uploaded source image across fresh engine guards for point, invert, and refinement changes', async () => { + const uploadIntermediate = vi.fn(() => Promise.resolve({ height: 100, imageName: 'source.png', width: 100 })); + const runGraph = vi.fn(() => Promise.resolve({ height: 100, imageName: 'mask.png', origin: 'test', width: 100 })); + const h = await createSamHarness({ runGraph, uploadIntermediate }); + expect(getCanvasOperations(h.engine).startSelectObject('source')).toBe('started'); + getCanvasOperations(h.engine).updateSelectObjectSession({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: 2, y: 2 }], type: 'visual' }, + }); + await expect(getCanvasOperations(h.engine).processSelectObjectSession()).resolves.toBe('published'); + + getCanvasOperations(h.engine).updateSelectObjectSession({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: 3, y: 3 }], type: 'visual' }, + }); + await expect(getCanvasOperations(h.engine).processSelectObjectSession()).resolves.toBe('published'); + getCanvasOperations(h.engine).updateSelectObjectSession({ invert: true }); + await expect(getCanvasOperations(h.engine).processSelectObjectSession()).resolves.toBe('published'); + getCanvasOperations(h.engine).updateSelectObjectSession({ applyPolygonRefinement: true }); + await expect(getCanvasOperations(h.engine).processSelectObjectSession()).resolves.toBe('published'); + + expect(uploadIntermediate).toHaveBeenCalledOnce(); + expect(runGraph).toHaveBeenCalledTimes(4); + h.engine.lifecycle.dispose(); + }); + + it('keeps the selection when starting on the selected layer and selects a non-selected source', async () => { + const h = await createSamHarness({ layers: [samLayer('first'), samLayer('second', 30)] }); + + expect(getCanvasOperations(h.engine).startSelectObject('first')).toBe('started'); + expect(h.store.dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'setCanvasSelectedLayer' })); + + expect(getCanvasOperations(h.engine).startSelectObject('second')).toBe('started'); + expect(h.store.dispatch).toHaveBeenCalledWith({ id: 'second', type: 'setCanvasSelectedLayer' }); + h.engine.lifecycle.dispose(); + }); + + it('runs a transformed layer rect through upload, local visual graph, metadata, and decoded rect', async () => { + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend: StubRasterBackend = { + ...base, + createImageBitmap: () => + Promise.resolve({ close: () => undefined, height: 20, width: 30 } as unknown as ImageBitmap), + createSurface: (width, height) => { + const surface = base.createSurface(width, height); + surfaces.push(surface); + return surface; + }, + }; + const uploadIntermediate = vi.fn(async (source: Blob) => { + expect(await source.text()).toBe('stub-surface-30x20'); + return { height: 20, imageName: 'layer-source.png', width: 30 }; + }); + const runGraph = vi.fn['runGraph']>((options) => { + expect(options.graph.nodes['sam-segment']).toMatchObject({ + bounding_boxes: [{ x_max: 20, x_min: 0, y_max: 18, y_min: 3 }], + point_lists: [{ points: [{ label: 1, x: 5, y: 8 }] }], + }); + return Promise.resolve({ height: 20, imageName: 'mask.png', origin: 'test', width: 30 }); + }); + const imageResolver = vi.fn(() => Promise.resolve(new Blob(['mask']))); + // A 20x20 shape scaled 1.5x horizontally and translated: baked world rect {-6, 9, 30, 20}. + const scaled: CanvasRasterLayerContractV2 = { + ...samLayer('scaled'), + source: { fill: '#fff', height: 20, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 20 }, + transform: { rotation: 0, scaleX: 1.5, scaleY: 1, x: -6, y: 9 }, + }; + const h = await createSamHarness({ + backend, + imageResolver, + layers: [scaled, samLayer('other', 40)], + runGraph, + uploadIntermediate, + }); + expect(getCanvasOperations(h.engine).startSelectObject('scaled')).toBe('started'); + expect(getCanvasOperations(h.engine).stores.samSession.get()?.sourceRect).toEqual({ + height: 20, + width: 30, + x: -6, + y: 9, + }); + getCanvasOperations(h.engine).updateSelectObjectSession({ + input: { + bbox: { height: 15, width: 20, x: -6, y: 12 }, + excludePoints: [], + includePoints: [{ x: -1, y: 17 }], + type: 'visual', + }, + }); + + await expect(getCanvasOperations(h.engine).processSelectObjectSession()).resolves.toBe('published'); + h.raf.flush(); + + expect(uploadIntermediate).toHaveBeenCalledOnce(); + expect(runGraph).toHaveBeenCalledOnce(); + expect(imageResolver).toHaveBeenCalledWith('mask.png', expect.any(AbortSignal)); + expect(surfaces.some((surface) => surface.width === 30 && surface.height === 20)).toBe(true); + expect(h.overlay.surface.callLog).toContainEqual({ + args: [expect.anything(), -6, 9, 30, 20], + op: 'drawImage', + }); + h.engine.lifecycle.dispose(); + }); + + it('builds the prompt path and reports a decoded preview', async () => { + const runGraph = vi.fn['runGraph']>((options) => { + expect(options.graph.nodes['sam-detect']).toMatchObject({ prompt: 'red car', type: 'grounding_dino' }); + return Promise.resolve({ height: 100, imageName: 'prompt-mask.png', origin: 'test', width: 100 }); + }); + const h = await createSamHarness({ runGraph }); + getCanvasOperations(h.engine).startSelectObject('source'); + getCanvasOperations(h.engine).updateSelectObjectSession({ input: { prompt: ' red car ', type: 'prompt' } }); + + await expect(getCanvasOperations(h.engine).processSelectObjectSession()).resolves.toBe('published'); + + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ hasPreview: true, status: 'ready' }); + h.engine.lifecycle.dispose(); + }); + + it('rejects SAM metadata dimensions that do not match the decoded preview rect', async () => { + const h = await createSamHarness({ + runGraph: () => Promise.resolve({ height: 99, imageName: 'bad-mask.png', origin: 'test', width: 100 }), + }); + getCanvasOperations(h.engine).startSelectObject('source'); + getCanvasOperations(h.engine).updateSelectObjectSession({ input: { prompt: 'cat', type: 'prompt' } }); + + await expect(getCanvasOperations(h.engine).processSelectObjectSession()).resolves.toBe('error'); + + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ + error: { code: 'output-dimension', detail: 'SAM output dimensions 100x99 do not match 100x100.' }, + hasPreview: false, + status: 'error', + }); + h.engine.lifecycle.dispose(); + }); + + it('reports preview decode failures without publishing a stale surface', async () => { + const h = await createSamHarness({ imageResolver: () => Promise.reject(new Error('preview decode failed')) }); + getCanvasOperations(h.engine).startSelectObject('source'); + getCanvasOperations(h.engine).updateSelectObjectSession({ input: { prompt: 'cat', type: 'prompt' } }); + + await expect(getCanvasOperations(h.engine).processSelectObjectSession()).resolves.toBe('error'); + + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ + error: { code: 'decode', detail: 'preview decode failed' }, + hasPreview: false, + status: 'error', + }); + h.engine.lifecycle.dispose(); + }); + + it.each([ + { decoded: { height: 100, width: 99 }, label: 'smaller' }, + { decoded: { height: 100, width: 101 }, label: 'larger' }, + { decoded: { height: 100, width: Number.NaN }, label: 'non-finite' }, + { decoded: { height: 100, width: 99.5 }, label: 'non-integer' }, + ])('rejects a $label decoded bitmap before surface allocation and closes it', async ({ decoded }) => { + const close = vi.fn(); + const base = createTestStubRasterBackend(); + let didDecode = false; + const previewSurfaceAllocations = vi.fn(); + const backend = { + ...base, + createImageBitmap: () => { + didDecode = true; + return Promise.resolve({ ...decoded, close } as unknown as ImageBitmap); + }, + createSurface: (width: number, height: number) => { + if (didDecode) { + previewSurfaceAllocations(width, height); + } + return base.createSurface(width, height); + }, + }; + const h = await createSamHarness({ backend }); + getCanvasOperations(h.engine).startSelectObject('source'); + getCanvasOperations(h.engine).updateSelectObjectSession({ input: { prompt: 'cat', type: 'prompt' } }); + await expect(getCanvasOperations(h.engine).processSelectObjectSession()).resolves.toBe('error'); + + expect(close).toHaveBeenCalledOnce(); + expect(previewSurfaceAllocations).not.toHaveBeenCalled(); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ + error: { + code: 'output-dimension', + detail: `Decoded Select Object preview dimensions ${String(decoded.width)}x${decoded.height} do not match SAM output 100x100 and preview rect 100x100.`, + }, + hasPreview: false, + status: 'error', + }); + h.engine.lifecycle.dispose(); + }); + + it('saves the guarded preview to the selection mask and exits only after success', async () => { + const { backend, surfaces } = createOpaqueSamBackend(); + const h = await createCommittingSamHarness(() => Promise.resolve(new Blob()), backend); + const makeDurable = vi.fn(() => Promise.resolve()); + + const previewCalls = surfaces.find((surface) => + surface.callLog.some( + (entry) => entry.op === 'set' && entry.args[0] === 'fillStyle' && entry.args[1] === '#38bdf8' + ) + )?.callLog; + expect(previewCalls).toEqual( + expect.arrayContaining([ + { args: ['globalCompositeOperation', 'source-in'], op: 'set' }, + { args: ['fillStyle', '#38bdf8'], op: 'set' }, + ]) + ); + + await expect(getCanvasOperations(h.engine).saveSelectObjectSession('selection', makeDurable)).resolves.toEqual({ + status: 'selected', + }); + + expect(makeDurable).not.toHaveBeenCalled(); + expect(h.engine.document.getDocument()).toEqual(h.document); + expect(h.engine.stores.hasSelection.get()).toBe(true); + expect(h.engine.selection.getSelectionBounds()).toEqual(h.document.bbox); + expect(h.engine.selection.getSelectionMaskRect()).toEqual(h.document.bbox); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + expect(h.engine.stores.activeTool.get()).toBe('view'); + h.engine.lifecycle.dispose(); + }); + + it('Apply replaces the source layer content in place with one undoable history entry', async () => { + const h = await createCommittingSamHarness(); + const makeDurable = vi.fn(() => Promise.resolve()); + + await expect(getCanvasOperations(h.engine).applySelectObjectSession(makeDurable)).resolves.toEqual({ + layerId: 'source', + status: 'committed', + }); + + expect(makeDurable).toHaveBeenCalledOnce(); + expect(makeDurable).toHaveBeenCalledWith('sam-mask.png'); + const replaced = h.engine.document.getDocument()!.layers.find((layer) => layer.id === 'source')!; + expect(replaced).toMatchObject({ + source: { + bitmap: { height: 100, imageName: 'sam-mask.png', width: 100 }, + offset: { x: 0, y: 0 }, + type: 'paint', + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }); + expect(h.engine.document.getDocument()!.layers).toHaveLength(1); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + expect(h.engine.stores.activeTool.get()).toBe('view'); + + expect(h.engine.stores.canUndo.get()).toBe(true); + h.engine.history.undo(); + expect(h.engine.document.getDocument()).toEqual(h.document); + expect(h.engine.stores.canUndo.get()).toBe(false); + h.engine.history.redo(); + expect(h.engine.document.getDocument()!.layers.find((layer) => layer.id === 'source')).toEqual(replaced); + h.engine.lifecycle.dispose(); + }); + + it('blocks Select Object actions called directly after an external lock and still allows Cancel', async () => { + const h = await createCommittingSamHarness(); + const makeDurable = vi.fn(() => Promise.resolve()); + const before = getCanvasOperations(h.engine).stores.samSession.get(); + + h.engine.tools.setInteractionLocked(true); + + expect( + getCanvasOperations(h.engine).updateSelectObjectSession({ input: { prompt: 'changed', type: 'prompt' } }) + ).toBe('blocked'); + expect(getCanvasOperations(h.engine).resetSelectObjectSession()).toBe('blocked'); + await expect(getCanvasOperations(h.engine).processSelectObjectSession()).resolves.toBe('blocked'); + await expect(getCanvasOperations(h.engine).applySelectObjectSession(makeDurable)).resolves.toEqual({ + status: 'locked', + }); + await expect(getCanvasOperations(h.engine).saveSelectObjectSession('raster', makeDurable)).resolves.toEqual({ + status: 'locked', + }); + expect(makeDurable).not.toHaveBeenCalled(); + expect(h.engine.document.getDocument()).toEqual(h.document); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toEqual(before); + + getCanvasOperations(h.engine).cancelSelectObjectSession(); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + h.engine.tools.setInteractionLocked(false); + expect(h.engine.stores.activeTool.get()).toBe('view'); + expect(getCanvasOperations(h.engine).updateSelectObjectSession({ invert: false })).toBe('stale'); + expect(getCanvasOperations(h.engine).resetSelectObjectSession()).toBe('stale'); + h.engine.lifecycle.dispose(); + }); + + it('blocks Select Object save mutation when an external lock begins during durability', async () => { + const h = await createCommittingSamHarness(); + const durable = createDeferred(); + const pending = getCanvasOperations(h.engine).saveSelectObjectSession('raster', () => durable.promise); + await vi.waitFor(() => expect(getCanvasOperations(h.engine).stores.samSession.get()?.status).toBe('committing')); + + h.engine.tools.setInteractionLocked(true); + durable.resolve(); + + await expect(pending).resolves.toEqual({ status: 'locked' }); + expect(h.engine.document.getDocument()).toEqual(h.document); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ hasPreview: true, status: 'ready' }); + getCanvasOperations(h.engine).cancelSelectObjectSession(); + h.engine.lifecycle.dispose(); + }); + + it('blocks Select Object apply mutation when an external lock begins during final decode', async () => { + const finalDecode = createDeferred(); + let resolutions = 0; + const h = await createCommittingSamHarness(() => { + resolutions += 1; + return resolutions === 1 ? Promise.resolve(new Blob()) : finalDecode.promise; + }); + const pending = getCanvasOperations(h.engine).applySelectObjectSession(() => Promise.resolve()); + await vi.waitFor(() => expect(resolutions).toBe(2)); + + h.engine.tools.setInteractionLocked(true); + finalDecode.resolve(new Blob()); + + await expect(pending).resolves.not.toMatchObject({ status: 'committed' }); + expect(h.engine.document.getDocument()).toEqual(h.document); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ hasPreview: true, status: 'ready' }); + getCanvasOperations(h.engine).cancelSelectObjectSession(); + h.engine.lifecycle.dispose(); + }); + + it('interrupts Select Object processing on an external lock without losing inputs or operation', async () => { + const graph = createDeferred<{ height: number; imageName: string; origin: string; width: number }>(); + const runGraph = vi.fn(() => graph.promise); + const h = await createSamHarness({ runGraph }); + expect(getCanvasOperations(h.engine).startSelectObject('source')).toBe('started'); + const input = { prompt: 'keep this prompt', type: 'prompt' as const }; + expect(getCanvasOperations(h.engine).updateSelectObjectSession({ input, invert: true })).toBe('updated'); + const pending = getCanvasOperations(h.engine).processSelectObjectSession(); + expect(getCanvasOperations(h.engine).stores.samSession.get()?.status).toBe('preparing-source'); + await vi.waitFor(() => expect(runGraph).toHaveBeenCalledOnce()); + + h.engine.tools.setInteractionLocked(true); + graph.resolve({ height: 100, imageName: 'late.png', origin: 'test', width: 100 }); + + await expect(pending).resolves.toBe('stale'); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ + hasPreview: false, + input, + invert: true, + status: 'ready', + }); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toMatchObject({ phase: 'ready', status: 'active' }); + getCanvasOperations(h.engine).cancelSelectObjectSession(); + h.engine.lifecycle.dispose(); + }); + + it.each(['input', 'reset', 'process'] as const)( + 'does not apply or close when %s invalidates ownership during final selection decode', + async (kind) => { + const finalDecode = createDeferred(); + let resolutions = 0; + const h = await createCommittingSamHarness(() => { + resolutions += 1; + return resolutions === 1 ? Promise.resolve(new Blob()) : finalDecode.promise; + }); + const pending = getCanvasOperations(h.engine).applySelectObjectSession(() => Promise.resolve()); + await vi.waitFor(() => expect(resolutions).toBe(2)); + + await invalidateSelectObjectCommit(h.engine, kind); + finalDecode.resolve(new Blob()); + + await expect(pending).resolves.not.toMatchObject({ status: 'committed' }); + expect(h.engine.document.getDocument()).toEqual(h.document); + expect(getCanvasOperations(h.engine).stores.samSession.get()).not.toBeNull(); + h.engine.lifecycle.dispose(); + } + ); + + it('does not invalidate a pending save when preview isolation changes', async () => { + const h = await createCommittingSamHarness(); + const durable = createDeferred(); + const pending = getCanvasOperations(h.engine).saveSelectObjectSession('inpaint_mask', () => durable.promise); + + getCanvasOperations(h.engine).updateSelectObjectSession({ isolatedPreview: false }); + durable.resolve(); + + await expect(pending).resolves.toMatchObject({ status: 'committed' }); + expect(h.engine.document.getDocument()?.layers[0]?.type).toBe('inpaint_mask'); + h.engine.lifecycle.dispose(); + }); + + it('Reset clears inputs, preview, and error while keeping the operation active', async () => { + const h = await createCommittingSamHarness(); + getCanvasOperations(h.engine).updateSelectObjectSession({ input: { prompt: 'cat', type: 'prompt' }, invert: true }); + + getCanvasOperations(h.engine).resetSelectObjectSession(); + + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ + error: null, + hasPreview: false, + input: { bbox: null, excludePoints: [], includePoints: [], type: 'visual' }, + invert: false, + status: 'ready', + }); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toMatchObject({ status: 'active' }); + h.engine.lifecycle.dispose(); + }); + + it.each([ + ['raster', 'Segmented Object'], + ['control', 'Segmented Object Control'], + ['inpaint_mask', null], + ['regional_guidance', null], + ] as const)( + 'promotes then atomically saves the guarded preview as %s at the top of its group, keeping the session', + async (target, copyName) => { + const calls: string[] = []; + const h = await createCommittingSamHarness(); + + const result = await getCanvasOperations(h.engine).saveSelectObjectSession(target, () => { + calls.push('durable'); + return Promise.resolve(); + }); + + expect(result.status).toBe('committed'); + expect(calls[0]).toBe('durable'); + expect(h.engine.document.getDocument()?.layers[0]?.type).toBe(target); + expect(h.engine.document.getDocument()?.layers[1]?.id).toBe('source'); + const created = h.engine.document.getDocument()?.layers[0]; + if (!created) { + throw new Error('expected a saved object layer'); + } + if (created.type === 'raster' || created.type === 'control') { + expect(created.name).toBe(copyName); + expect(created.source).toMatchObject({ offset: { x: 0, y: 0 }, type: 'paint' }); + } else { + expect(created.mask).toMatchObject({ offset: { x: 0, y: 0 } }); + } + expect(created.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }); + + // Save As keeps the session and its preview open; only Apply, the + // Selection target, and Cancel end it. + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ + hasPreview: true, + status: 'ready', + }); + expect(h.engine.stores.activeTool.get()).toBe('sam'); + getCanvasOperations(h.engine).cancelSelectObjectSession(); + + expect(h.engine.stores.canUndo.get()).toBe(true); + h.engine.history.undo(); + expect(h.engine.document.getDocument()?.layers.map((layer) => layer.id)).toEqual(['source']); + h.engine.history.redo(); + expect(h.engine.document.getDocument()?.layers[0]?.type).toBe(target); + h.engine.lifecycle.dispose(); + } + ); + + it('keeps the session open across saves so a second save also succeeds without touching the source', async () => { + const h = await createCommittingSamHarness(); + + await expect( + getCanvasOperations(h.engine).saveSelectObjectSession('raster', () => Promise.resolve()) + ).resolves.toMatchObject({ + status: 'committed', + }); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ hasPreview: true, status: 'ready' }); + + await expect( + getCanvasOperations(h.engine).saveSelectObjectSession('raster', () => Promise.resolve()) + ).resolves.toMatchObject({ + status: 'committed', + }); + + expect(h.engine.document.getDocument()?.layers.map((layer) => layer.name)).toEqual([ + 'Segmented Object', + 'Segmented Object', + 'source', + ]); + expect(h.engine.document.getDocument()?.layers[2]).toEqual(h.document.layers[0]); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ hasPreview: true, status: 'ready' }); + expect(h.engine.stores.activeTool.get()).toBe('sam'); + h.engine.lifecycle.dispose(); + }); + + it('saves the exact non-zero source rect origin and dimensions', async () => { + const rect = { height: 30, width: 40, x: -11, y: 7 }; + const h = await createCommittingSamHarness(undefined, undefined, rect); + + const result = await getCanvasOperations(h.engine).saveSelectObjectSession('raster', () => Promise.resolve()); + + expect(result.status).toBe('committed'); + const created = h.engine.document.getDocument()?.layers[0]; + expect(created?.type).toBe('raster'); + if (created?.type === 'raster') { + expect(created.source).toMatchObject({ + bitmap: { height: rect.height, width: rect.width }, + offset: { x: rect.x, y: rect.y }, + }); + } + h.engine.lifecycle.dispose(); + }); + + it('publishes a committing phase and mutually excludes Apply and Save', async () => { + const h = await createCommittingSamHarness(); + const durable = createDeferred(); + const pending = getCanvasOperations(h.engine).saveSelectObjectSession('raster', () => durable.promise); + + expect(getCanvasOperations(h.engine).stores.samSession.get()?.status).toBe('committing'); + await expect(getCanvasOperations(h.engine).applySelectObjectSession(() => Promise.resolve())).resolves.toEqual({ + status: 'busy', + }); + await expect( + getCanvasOperations(h.engine).saveSelectObjectSession('control', () => Promise.resolve()) + ).resolves.toEqual({ + status: 'busy', + }); + durable.resolve(); + await expect(pending).resolves.toMatchObject({ status: 'committed' }); + h.engine.lifecycle.dispose(); + }); + + it.each(['input', 'reset', 'process'] as const)( + 'does not save or close when %s invalidates ownership during durability promotion', + async (kind) => { + const h = await createCommittingSamHarness(); + const durable = createDeferred(); + const pending = getCanvasOperations(h.engine).saveSelectObjectSession('regional_guidance', () => durable.promise); + + await invalidateSelectObjectCommit(h.engine, kind); + durable.resolve(); + + await expect(pending).resolves.not.toMatchObject({ status: 'committed' }); + expect(h.engine.document.getDocument()).toEqual(h.document); + expect(getCanvasOperations(h.engine).stores.samSession.get()).not.toBeNull(); + h.engine.lifecycle.dispose(); + } + ); + + it.each([ + ['raster', 'input'], + ['raster', 'reset'], + ['raster', 'process'], + ['control', 'input'], + ['control', 'reset'], + ['control', 'process'], + ] as const)('does not commit %s when %s invalidates ownership during final decode', async (target, kind) => { + const finalDecode = createDeferred(); + let resolutions = 0; + const h = await createCommittingSamHarness(() => { + resolutions += 1; + return resolutions === 1 ? Promise.resolve(new Blob()) : finalDecode.promise; + }); + const pending = getCanvasOperations(h.engine).saveSelectObjectSession(target, () => Promise.resolve()); + await vi.waitFor(() => expect(resolutions).toBe(2)); + + await invalidateSelectObjectCommit(h.engine, kind); + finalDecode.resolve(new Blob()); + + await expect(pending).resolves.not.toMatchObject({ status: 'committed' }); + expect(h.engine.document.getDocument()).toEqual(h.document); + expect(getCanvasOperations(h.engine).stores.samSession.get()).not.toBeNull(); + h.engine.lifecycle.dispose(); + }); + + it('does not report an old durability failure into a replacement session', async () => { + const document = samDocument([samLayer('first'), samLayer('second', 30)]); + const reactive = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: withSamPreviewBitmapDimensions(createTestStubRasterBackend()), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: reactive.projectId, + selectObjectDeps: { + runGraph: () => Promise.resolve({ height: 100, imageName: 'sam-mask.png', origin: 'test', width: 100 }), + uploadIntermediate: () => Promise.resolve({ height: 100, imageName: 'sam-source.png', width: 100 }), + }, + store: reactive.store, + }); + await engine.exports.exportLayerPixels('first'); + await engine.exports.exportLayerPixels('second'); + getCanvasOperations(engine).startSelectObject('first'); + getCanvasOperations(engine).updateSelectObjectSession({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: 5, y: 5 }], type: 'visual' }, + }); + await getCanvasOperations(engine).processSelectObjectSession(); + let rejectDurability!: (error: Error) => void; + const durability = new Promise((_resolve, reject) => { + rejectDurability = reject; + }); + const oldSave = getCanvasOperations(engine).saveSelectObjectSession('raster', () => durability); + + expect(getCanvasOperations(engine).startSelectObject('second')).toBe('started'); + rejectDurability(new Error('old failure')); + + await expect(oldSave).resolves.toMatchObject({ status: 'failed' }); + expect(getCanvasOperations(engine).stores.samSession.get()).toMatchObject({ error: null }); + engine.lifecycle.dispose(); + }); + + it('does not close a replacement session when the old selection save finishes authoritatively', async () => { + const document = samDocument([samLayer('first'), samLayer('second', 30)]); + const reactive = createReducerBackedStore(document); + const { backend } = createOpaqueSamBackend(); + const engine = createCanvasEngine({ + backend: withSamPreviewBitmapDimensions(backend), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: reactive.projectId, + selectObjectDeps: { + runGraph: () => Promise.resolve({ height: 100, imageName: 'sam-mask.png', origin: 'test', width: 100 }), + uploadIntermediate: () => Promise.resolve({ height: 100, imageName: 'sam-source.png', width: 100 }), + }, + store: reactive.store, + }); + await engine.exports.exportLayerPixels('first'); + await engine.exports.exportLayerPixels('second'); + getCanvasOperations(engine).startSelectObject('first'); + getCanvasOperations(engine).updateSelectObjectSession({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: 5, y: 5 }], type: 'visual' }, + }); + await getCanvasOperations(engine).processSelectObjectSession(); + let replaced = false; + const unsubscribe = engine.stores.hasSelection.subscribe(() => { + if (!replaced && engine.stores.hasSelection.get()) { + replaced = true; + getCanvasOperations(engine).startSelectObject('second'); + } + }); + + await expect( + getCanvasOperations(engine).saveSelectObjectSession('selection', () => Promise.resolve()) + ).resolves.toEqual({ + status: 'selected', + }); + + expect(getCanvasOperations(engine).stores.samSession.get()).toMatchObject({ error: null }); + unsubscribe(); + engine.lifecycle.dispose(); + }); + + it.each(['apply', 'save'] as const)( + 'preserves a replacement started by the SAM subscriber when old %s succeeds', + async (kind) => { + const document = samDocument([samLayer('first'), samLayer('second', 30)]); + const reactive = createReducerBackedStore(document); + const { backend } = createOpaqueSamBackend(); + const engine = createCanvasEngine({ + backend: withSamPreviewBitmapDimensions(backend), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: reactive.projectId, + selectObjectDeps: { + runGraph: () => Promise.resolve({ height: 100, imageName: 'sam-mask.png', origin: 'test', width: 100 }), + uploadIntermediate: () => Promise.resolve({ height: 100, imageName: 'sam-source.png', width: 100 }), + }, + store: reactive.store, + }); + await engine.exports.exportLayerPixels('first'); + await engine.exports.exportLayerPixels('second'); + getCanvasOperations(engine).startSelectObject('first'); + getCanvasOperations(engine).updateSelectObjectSession({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: 5, y: 5 }], type: 'visual' }, + }); + await getCanvasOperations(engine).processSelectObjectSession(); + let sawCommitting = false; + let replaced = false; + const unsubscribe = getCanvasOperations(engine).stores.samSession.subscribe(() => { + const snapshot = getCanvasOperations(engine).stores.samSession.get(); + if (snapshot?.status === 'committing') { + sawCommitting = true; + } else if (sawCommitting && !replaced) { + replaced = true; + expect(getCanvasOperations(engine).startSelectObject('second')).toBe('started'); + } + }); + + const result = + kind === 'apply' + ? await getCanvasOperations(engine).applySelectObjectSession(() => Promise.resolve()) + : await getCanvasOperations(engine).saveSelectObjectSession('inpaint_mask', () => Promise.resolve()); + + expect(result.status).toBe('committed'); + expect(replaced).toBe(true); + expect(getCanvasOperations(engine).stores.samSession.get()).toMatchObject({ error: null }); + expect(engine.stores.activeTool.get()).toBe('sam'); + unsubscribe(); + engine.lifecycle.dispose(); + } + ); + + it('keeps the session and exact preview retryable when durability fails', async () => { + const h = await createCommittingSamHarness(); + const previewInput = getCanvasOperations(h.engine).stores.samSession.get()?.input; + + await expect( + getCanvasOperations(h.engine).saveSelectObjectSession('raster', () => + Promise.reject(new Error('promotion failed')) + ) + ).resolves.toEqual({ message: 'promotion failed', status: 'failed' }); + + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ + error: { code: 'unknown', detail: 'promotion failed' }, + hasPreview: true, + }); + expect(getCanvasOperations(h.engine).stores.samSession.get()?.input).toEqual(previewInput); + expect(h.engine.document.getDocument()).toEqual(h.document); + await expect( + getCanvasOperations(h.engine).saveSelectObjectSession('raster', () => Promise.resolve()) + ).resolves.toMatchObject({ + status: 'committed', + }); + h.engine.lifecycle.dispose(); + }); + + it('keeps the session and preview retryable when the structural save is rejected', async () => { + const h = await createCommittingSamHarness(); + const previewInput = getCanvasOperations(h.engine).stores.samSession.get()?.input; + h.dispatch.mockImplementationOnce(() => { + throw new Error('commit rejected'); + }); + + await expect( + getCanvasOperations(h.engine).saveSelectObjectSession('inpaint_mask', () => Promise.resolve()) + ).resolves.toEqual({ + message: 'commit rejected', + status: 'failed', + }); + + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ + error: { code: 'unknown', detail: 'commit rejected' }, + hasPreview: true, + }); + expect(getCanvasOperations(h.engine).stores.samSession.get()?.input).toEqual(previewInput); + expect(h.engine.document.getDocument()).toEqual(h.document); + h.engine.lifecycle.dispose(); + }); + + it('does not commit a preview that becomes stale during durability promotion', async () => { + const h = await createCommittingSamHarness(); + const durable = createDeferred(); + const pending = getCanvasOperations(h.engine).saveSelectObjectSession('control', () => durable.promise); + h.dispatch({ id: 'source', patch: { opacity: 0.5 }, type: 'updateCanvasLayer' }); + durable.resolve(); + + await expect(pending).resolves.toEqual({ status: 'stale' }); + expect(h.engine.document.getDocument()?.layers).toHaveLength(1); + expect(h.engine.stores.canUndo.get()).toBe(false); + h.engine.lifecycle.dispose(); + }); + + it('does not treat a reentrant unrelated layer mutation as the owned Save insertion', async () => { + const document = samDocument([samLayer('source')]); + const reducer = createReducerBackedStore(document); + let engine!: CanvasEngine; + let reentered = false; + let sessionDuringReentry: ReturnType['stores']['samSession']['get']> = null; + reducer.store.subscribe(() => { + const current = reducer.store.getState().projects[0]?.canvas.document; + if (!reentered && current?.layers.length === 2) { + reentered = true; + reducer.store.dispatch({ id: 'source', patch: { opacity: 0.5 }, type: 'updateCanvasLayer' }); + sessionDuringReentry = getCanvasOperations(engine).stores.samSession.get(); + } + }); + engine = createCanvasEngine({ + backend: withSamPreviewBitmapDimensions(createTestStubRasterBackend()), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: reducer.projectId, + selectObjectDeps: { + runGraph: () => Promise.resolve({ height: 100, imageName: 'sam-mask.png', origin: 'test', width: 100 }), + uploadIntermediate: () => Promise.resolve({ height: 100, imageName: 'sam-source.png', width: 100 }), + }, + store: reducer.store, + }); + await engine.exports.exportLayerPixels('source'); + expect(getCanvasOperations(engine).startSelectObject('source')).toBe('started'); + getCanvasOperations(engine).updateSelectObjectSession({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: 5, y: 5 }], type: 'visual' }, + }); + await expect(getCanvasOperations(engine).processSelectObjectSession()).resolves.toBe('published'); + + await expect( + getCanvasOperations(engine).saveSelectObjectSession('raster', () => Promise.resolve()) + ).resolves.toMatchObject({ + status: 'committed', + }); + + expect(reentered).toBe(true); + expect(sessionDuringReentry).toBeNull(); + expect(getCanvasOperations(engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + expect(engine.document.getDocument()?.layers.find((layer) => layer.id === 'source')?.opacity).toBe(0.5); + engine.lifecycle.dispose(); + }); + + it.each(['source', 'project', 'document'] as const)('aborts a pending save on %s invalidation', async (kind) => { + const h = await createSamHarness(); + getCanvasOperations(h.engine).startSelectObject('source'); + getCanvasOperations(h.engine).updateSelectObjectSession({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: 5, y: 5 }], type: 'visual' }, + }); + await getCanvasOperations(h.engine).processSelectObjectSession(); + const durable = createDeferred(); + const pending = getCanvasOperations(h.engine).saveSelectObjectSession('inpaint_mask', () => durable.promise); + const document = h.engine.document.getDocument()!; + + if (kind === 'source') { + h.setDocument({ ...document, layers: [{ ...document.layers[0]!, opacity: 0.5 }] }); + } else if (kind === 'project') { + h.setActiveProjectId('p2'); + } else { + h.setDocument({ ...document }, 1); + } + durable.resolve(); + + if (kind === 'project') { + await expect(pending).resolves.toMatchObject({ status: 'committed' }); + } else { + await expect(pending).resolves.not.toMatchObject({ status: 'committed' }); + } + if (kind === 'project') { + expect(getCanvasOperations(h.engine).stores.samSession.get()).not.toBeNull(); + } else { + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + } + h.engine.lifecycle.dispose(); + }); + + it('does not commit when Cancel closes the operation during durability promotion', async () => { + const h = await createCommittingSamHarness(); + const durable = createDeferred(); + const pending = getCanvasOperations(h.engine).saveSelectObjectSession('regional_guidance', () => durable.promise); + + getCanvasOperations(h.engine).cancelSelectObjectSession(); + durable.resolve(); + + await expect(pending).resolves.toEqual({ status: 'stale' }); + expect(h.engine.document.getDocument()).toEqual(h.document); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + expect(h.engine.stores.canUndo.get()).toBe(false); + h.engine.lifecycle.dispose(); + }); + + it('aborts a raster save when Cancel lands during final image decoding', async () => { + const finalDecode = createDeferred(); + let resolutions = 0; + const h = await createCommittingSamHarness(() => { + resolutions += 1; + return resolutions === 1 ? Promise.resolve(new Blob()) : finalDecode.promise; + }); + const pending = getCanvasOperations(h.engine).saveSelectObjectSession('raster', () => Promise.resolve()); + await vi.waitFor(() => expect(resolutions).toBe(2)); + + getCanvasOperations(h.engine).cancelSelectObjectSession(); + finalDecode.resolve(new Blob()); + + await expect(pending).resolves.toEqual({ status: 'aborted' }); + expect(h.engine.document.getDocument()).toEqual(h.document); + expect(h.engine.stores.canUndo.get()).toBe(false); + h.engine.lifecycle.dispose(); + }); + + it('processes a sole bbox near-edge point after canonicalizing it to the last bbox pixel', async () => { + const runGraph = vi.fn(() => + Promise.resolve({ height: 100, imageName: 'sam-mask.png', origin: 'test', width: 100 }) + ); + const h = await createSamHarness({ runGraph }); + getCanvasOperations(h.engine).startSelectObject('source'); + + h.overlay.fire('pointerdown', pointerAt(99.8, 99.7)); + h.overlay.fire('pointerup', pointerAt(99.8, 99.7, { buttons: 0 })); + + expect(getCanvasOperations(h.engine).stores.samSession.get()?.input.includePoints).toEqual([{ x: 99, y: 99 }]); + await expect(getCanvasOperations(h.engine).processSelectObjectSession()).resolves.toBe('published'); + expect(runGraph).toHaveBeenCalledOnce(); + h.engine.lifecycle.dispose(); + }); + + it('uses the half-open generation bbox as the only point domain', async () => { + const h = await createSamHarness(); + expect(getCanvasOperations(h.engine).startSelectObject('source')).toBe('started'); + + h.overlay.fire('pointerdown', pointerAt(100, 50)); + h.overlay.fire('pointerup', pointerAt(100, 50, { buttons: 0 })); + h.overlay.fire('pointerdown', pointerAt(-0.01, 50)); + h.overlay.fire('pointerup', pointerAt(-0.01, 50, { buttons: 0 })); + expect(getCanvasOperations(h.engine).stores.samSession.get()?.input.includePoints).toEqual([]); + + h.overlay.fire('pointerdown', pointerAt(99.99, 99.99)); + h.overlay.fire('pointerup', pointerAt(99.99, 99.99, { buttons: 0 })); + expect(getCanvasOperations(h.engine).stores.samSession.get()?.input.includePoints).toEqual([{ x: 99, y: 99 }]); + h.engine.lifecycle.dispose(); + }); + + it('keeps viewport changes current but invalidates on cache clears', async () => { + const h = await createSamHarness(); + expect(getCanvasOperations(h.engine).startSelectObject('source')).toBe('started'); + + h.engine.viewport.getViewport().panBy({ x: 20, y: -10 }); + h.engine.viewport.getViewport().zoomAtPoint(2, { x: 50, y: 50 }); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toMatchObject({ + identity: { kind: 'select-object' }, + status: 'active', + }); + + await h.engine.diagnostics.clearCaches(); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + h.engine.lifecycle.dispose(); + }); + + it('survives bbox moves, layer reorders, and other-layer edits but cancels on a source-layer edit', async () => { + const h = await createSamHarness({ layers: [samLayer('source'), samLayer('other', 30)] }); + expect(getCanvasOperations(h.engine).startSelectObject('source')).toBe('started'); + + const document = h.engine.document.getDocument()!; + h.setDocument({ ...document, bbox: { height: 80, width: 90, x: 3, y: 4 } }); + expect(getCanvasOperations(h.engine).stores.samSession.get()).not.toBeNull(); + + const reordered = h.engine.document.getDocument()!; + h.setDocument({ ...reordered, layers: [...reordered.layers].reverse() }); + expect(getCanvasOperations(h.engine).stores.samSession.get()).not.toBeNull(); + + const withOtherEdited = h.engine.document.getDocument()!; + h.setDocument({ + ...withOtherEdited, + layers: withOtherEdited.layers.map((layer) => (layer.id === 'other' ? { ...layer, opacity: 0.5 } : layer)), + }); + expect(getCanvasOperations(h.engine).stores.samSession.get()).not.toBeNull(); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toMatchObject({ + identity: { kind: 'select-object', layerId: 'source' }, + status: 'active', + }); + + const withSourceEdited = h.engine.document.getDocument()!; + h.setDocument({ + ...withSourceEdited, + layers: withSourceEdited.layers.map((layer) => (layer.id === 'source' ? { ...layer, opacity: 0.25 } : layer)), + }); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + expect(h.engine.stores.activeTool.get()).toBe('view'); + h.engine.lifecycle.dispose(); + }); + + it('does not start from a stale cache whose replacement source is still decoding', async () => { + const nextImage = createDeferred(); + const outside = { + ...samLayer('moving-source'), + source: { + bitmap: { height: 10, imageName: 'outside.png', width: 10 }, + offset: { x: 150, y: 0 }, + type: 'paint' as const, + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + }; + const h = await createSamHarness({ + imageResolver: (imageName) => + imageName === 'inside.png' ? nextImage.promise : Promise.resolve(new Blob(['outside'])), + layers: [outside], + }); + const document = h.engine.document.getDocument()!; + h.setDocument({ + ...document, + layers: [ + { + ...outside, + source: { image: { height: 10, imageName: 'inside.png', width: 10 }, type: 'image' }, + }, + ], + }); + + expect(getCanvasOperations(h.engine).startSelectObject('moving-source')).toBe('not-ready'); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + + nextImage.resolve(new Blob(['inside'])); + await expect(h.engine.exports.exportLayerPixels('moving-source')).resolves.toMatchObject({ status: 'ok' }); + expect(getCanvasOperations(h.engine).startSelectObject('moving-source')).toBe('started'); + h.engine.lifecycle.dispose(); + }); + + it('refuses disabled and authoritatively empty source layers without rasterizing', async () => { + const imageResolver = vi.fn(() => Promise.reject(new Error('disabled layer must not rasterize'))); + const empty = { + ...samLayer('empty'), + source: { bitmap: null, offset: { x: 0, y: 0 }, type: 'paint' as const }, + }; + const disabled = { + ...samLayer('disabled'), + isEnabled: false, + source: { image: { height: 10, imageName: 'disabled.png', width: 10 }, type: 'image' as const }, + }; + const h = await createSamHarness({ imageResolver, layers: [disabled, empty] }); + + expect(getCanvasOperations(h.engine).startSelectObject('disabled')).toBe('disabled'); + expect(getCanvasOperations(h.engine).startSelectObject('empty')).toBe('not-ready'); + + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + expect(imageResolver).not.toHaveBeenCalled(); + h.engine.lifecycle.dispose(); + }); + + it('validates the source layer on start: missing, unsupported, disabled, and locked', async () => { + const mask: CanvasInpaintMaskLayerContract = { + ...samLayer('mask'), + mask: { bitmap: null, fill: { color: '#fff', style: 'solid' } }, + type: 'inpaint_mask', + }; + const h = await createSamHarness({ + layers: [ + mask, + { ...samLayer('disabled'), isEnabled: false }, + { ...samLayer('locked'), isLocked: true }, + samLayer('ready'), + ], + }); + + expect(getCanvasOperations(h.engine).startSelectObject('missing')).toBe('missing'); + expect(getCanvasOperations(h.engine).startSelectObject('mask')).toBe('unsupported'); + expect(getCanvasOperations(h.engine).startSelectObject('disabled')).toBe('disabled'); + expect(getCanvasOperations(h.engine).startSelectObject('locked')).toBe('locked'); + + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toEqual({ status: 'idle' }); + expect(h.engine.stores.activeTool.get()).toBe('view'); + + expect(getCanvasOperations(h.engine).startSelectObject('ready')).toBe('started'); + expect(h.engine.stores.activeTool.get()).toBe('sam'); + h.engine.lifecycle.dispose(); + }); + + it('cancels the owned session and decoded preview on a real tool switch', async () => { + const h = await createSamHarness(); + expect(getCanvasOperations(h.engine).startSelectObject('source')).toBe('started'); + getCanvasOperations(h.engine).updateSelectObjectSession({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: 5, y: 5 }], type: 'visual' }, + }); + await expect(getCanvasOperations(h.engine).processSelectObjectSession()).resolves.toBe('published'); + expect(getCanvasOperations(h.engine).stores.samSession.get()?.hasPreview).toBe(true); + + h.engine.tools.setTool('view'); + + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + h.engine.lifecycle.dispose(); + }); + + it('suppresses a late preview decode after cancellation and closes its bitmap', async () => { + const image = createDeferred(); + const bitmap = createDeferred(); + const close = vi.fn(); + const base = createTestStubRasterBackend(); + const backend = { ...base, createImageBitmap: vi.fn(() => bitmap.promise) }; + const h = await createSamHarness({ backend, imageResolver: () => image.promise }); + getCanvasOperations(h.engine).startSelectObject('source'); + getCanvasOperations(h.engine).updateSelectObjectSession({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: 5, y: 5 }], type: 'visual' }, + }); + const pending = getCanvasOperations(h.engine).processSelectObjectSession(); + await vi.waitFor(() => + expect(getCanvasOperations(h.engine).stores.samSession.get()?.status).toBe('rendering-preview') + ); + image.resolve(new Blob()); + await vi.waitFor(() => expect(backend.createImageBitmap).toHaveBeenCalledOnce()); + + getCanvasOperations(h.engine).cancelSelectObjectSession(); + bitmap.resolve({ close, height: 20, width: 20 } as unknown as ImageBitmap); + + await expect(pending).resolves.toBe('stale'); + expect(close).toHaveBeenCalledOnce(); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + h.engine.lifecycle.dispose(); + }); + + it('isolates only the source layer in the compositor while a preview is published', async () => { + const backend = createRecordingRasterBackend(); + backend.createImageBitmap = vi.fn(() => Promise.resolve(recordingBitmap('sam-mask', 100, 100))); + const h = await createSamHarness({ backend, layers: [samLayer('source'), samLayer('other', 30)] }); + h.engine.stores.checkerboard.set(false); + getCanvasOperations(h.engine).startSelectObject('source'); + getCanvasOperations(h.engine).updateSelectObjectSession({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: 5, y: 5 }], type: 'visual' }, + }); + await getCanvasOperations(h.engine).processSelectObjectSession(); + h.screen.surface.callLog.length = 0; + h.raf.flush(); + + expect(h.screen.surface.callLog.filter((entry) => entry.op === 'drawImage')).toHaveLength(1); + expect(h.screen.surface.callLog).toEqual( + expect.arrayContaining([ + { args: [0, 0, 100, 100], op: 'rect' }, + { args: [], op: 'clip' }, + ]) + ); + expect(h.engine.document.getDocument()!.layers.every((layer) => layer.isEnabled)).toBe(true); + h.engine.lifecycle.dispose(); + }); + + it('isolates the transformed source layer with display semantics while excluding other layers', async () => { + const backend = createRecordingRasterBackend(); + backend.createImageBitmap = vi.fn(() => Promise.resolve(recordingBitmap('decoded', 20, 15))); + // Paint bitmap 10x10 at offset (-4, 6), scaled 2x1.5 and translated (12, 8): + // baked world rect {4, 17, 20, 15}. + const raster: CanvasLayerContract = { + ...samLayer('adjusted-raster'), + adjustments: { brightness: 0.2, contrast: -0.1, saturation: 0.3 }, + blendMode: 'multiply', + opacity: 0.4, + source: { + bitmap: { height: 10, imageName: 'sparse.png', width: 10 }, + offset: { x: -4, y: 6 }, + type: 'paint', + }, + transform: { rotation: 0, scaleX: 2, scaleY: 1.5, x: 12, y: 8 }, + }; + const control: CanvasLayerContract = { + adapter: { beginEndStepPct: [0, 0.75], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + blendMode: 'screen', + id: 'control', + isEnabled: true, + isLocked: false, + name: 'Control', + opacity: 0.65, + source: { image: { height: 12, imageName: 'control.png', width: 14 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 30, y: 20 }, + type: 'control', + withTransparencyEffect: true, + }; + const mask: CanvasInpaintMaskLayerContract = { + ...samLayer('mask'), + mask: { bitmap: { height: 20, imageName: 'mask.png', width: 20 }, fill: { color: '#fff', style: 'solid' } }, + type: 'inpaint_mask', + }; + const h = await createSamHarness({ + backend, + layers: [mask, control, raster], + runGraph: () => Promise.resolve({ height: 15, imageName: 'sam-mask.png', origin: 'test', width: 20 }), + uploadIntermediate: () => Promise.resolve({ height: 15, imageName: 'sam-source.png', width: 20 }), + }); + h.engine.stores.checkerboard.set(false); + expect(getCanvasOperations(h.engine).startSelectObject('adjusted-raster')).toBe('started'); + expect(getCanvasOperations(h.engine).stores.samSession.get()?.sourceRect).toEqual({ + height: 15, + width: 20, + x: 4, + y: 17, + }); + getCanvasOperations(h.engine).updateSelectObjectSession({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: 5, y: 18 }], type: 'visual' }, + }); + await expect(getCanvasOperations(h.engine).processSelectObjectSession()).resolves.toBe('published'); + h.screen.surface.callLog.length = 0; + h.raf.flush(); + + const draws = h.screen.surface.callLog.filter((entry) => entry.op === 'drawImage'); + expect(draws).toHaveLength(1); + expect(h.screen.surface.callLog).toEqual( + expect.arrayContaining([ + { args: ['globalAlpha', 0.4], op: 'set' }, + { args: ['globalCompositeOperation', 'multiply'], op: 'set' }, + { args: [4, 17, 20, 15], op: 'rect' }, + ]) + ); + expect(h.screen.surface.callLog).not.toContainEqual({ args: ['globalAlpha', 0.65], op: 'set' }); + expect(h.screen.surface.callLog).not.toContainEqual({ args: ['globalCompositeOperation', 'screen'], op: 'set' }); + expect(adjustedSurfaceCacheGets).toContain('adjusted-raster'); + h.engine.lifecycle.dispose(); + }); + + it('encodes exactly the baked source layer surface, excluding other layers and previews', async () => { + const backend = createRecordingRasterBackend(); + const encoded: StubRasterSurface[] = []; + backend.encodeSurface = vi.fn((surface) => { + encoded.push(surface as StubRasterSurface); + return Promise.resolve(new Blob(['baked-layer'], { type: 'image/png' })); + }); + backend.createImageBitmap = vi.fn(() => Promise.resolve(recordingBitmap('decoded', 12, 24))); + // Paint bitmap 6x8 at offset (-4, 6), scaled 2x3 and translated (20, 30): + // baked world rect {12, 48, 12, 24}. + const raster: CanvasLayerContract = { + ...samLayer('raster'), + source: { + bitmap: { height: 8, imageName: 'raster.png', width: 6 }, + offset: { x: -4, y: 6 }, + type: 'paint', + }, + transform: { rotation: 0, scaleX: 2, scaleY: 3, x: 20, y: 30 }, + }; + const control: CanvasLayerContract = { + adapter: { beginEndStepPct: [0, 0.75], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + blendMode: 'normal', + id: 'control', + isEnabled: true, + isLocked: false, + name: 'Control', + opacity: 1, + source: { image: { height: 10, imageName: 'control.png', width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 40, y: 50 }, + type: 'control', + withTransparencyEffect: false, + }; + const mask: CanvasInpaintMaskLayerContract = { + ...samLayer('mask'), + mask: { bitmap: { height: 10, imageName: 'mask.png', width: 10 }, fill: { color: '#fff', style: 'solid' } }, + type: 'inpaint_mask', + }; + const h = await createSamHarness({ + backend, + layers: [mask, control, raster], + runGraph: () => Promise.resolve({ height: 24, imageName: 'sam-mask.png', origin: 'test', width: 12 }), + uploadIntermediate: () => Promise.resolve({ height: 24, imageName: 'sam-source.png', width: 12 }), + }); + h.setDocument({ ...h.engine.document.getDocument()!, bbox: { height: 70, width: 80, x: 10, y: 15 } }); + const rasterExport = await h.engine.exports.exportLayerPixels('raster'); + const controlExport = await h.engine.exports.exportLayerPixels('control'); + if (rasterExport.status !== 'ok' || controlExport.status !== 'ok') { + throw new Error('expected authoritative layer caches'); + } + await expect( + h.engine.previews.setGuardedFilterPreview( + 'raster', + { imageName: 'filter-preview.png', rect: rasterExport.rect }, + rasterExport.guard + ) + ).resolves.toBe('shown'); + h.engine.previews.setStagedPreview({ imageName: 'staged.png' }); + await flushMicrotasks(); + expect(getCanvasOperations(h.engine).startSelectObject('raster')).toBe('started'); + getCanvasOperations(h.engine).updateSelectObjectSession({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: 15, y: 50 }], type: 'visual' }, + }); + + await expect(getCanvasOperations(h.engine).processSelectObjectSession()).resolves.toBe('published'); + + const baked = encoded.find((surface) => surface.width === 12 && surface.height === 24); + expect(baked).toBeDefined(); + const log = baked!.callLog; + const draws = log.filter((entry) => entry.op === 'drawImage'); + expect(draws).toHaveLength(1); + expect(backend.drawSourcesFor(baked!)).toEqual([backend.surfaceId(rasterExport.surface as StubRasterSurface)]); + expect(draws.map((entry) => entry.args.slice(1))).toEqual([[-4, 6]]); + expect(log.filter((entry) => entry.op === 'setTransform').map((entry) => entry.args)).toEqual( + expect.arrayContaining([[2, 0, 0, 3, 8, -18]]) + ); + h.engine.lifecycle.dispose(); + }); + + it('tears down the session when its captured source contract changes', async () => { + const h = await createSamHarness(); + getCanvasOperations(h.engine).startSelectObject('source'); + + h.setDocument({ + ...h.engine.document.getDocument()!, + layers: [{ ...h.engine.document.getDocument()!.layers[0]!, opacity: 0.5 }], + }); + + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + expect(h.engine.stores.activeTool.get()).toBe('view'); + h.engine.lifecycle.dispose(); + }); + + it('owns only the latest session and suppresses in-flight work from the replaced session', async () => { + const output = createDeferred<{ height: number; imageName: string; origin: string; width: number }>(); + const layers = [samLayer('first'), samLayer('second', 30)]; + const h = await createSamHarness({ layers, runGraph: () => output.promise }); + getCanvasOperations(h.engine).startSelectObject('first'); + getCanvasOperations(h.engine).updateSelectObjectSession({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: 5, y: 5 }], type: 'visual' }, + }); + const pending = getCanvasOperations(h.engine).processSelectObjectSession(); + await vi.waitFor(() => + expect(getCanvasOperations(h.engine).stores.samSession.get()?.status).toBe('processing-sam') + ); + + expect(getCanvasOperations(h.engine).startSelectObject('second')).toBe('started'); + expect(getCanvasOperations(h.engine).stores.samSession.get()).not.toBeNull(); + output.resolve({ height: 100, imageName: 'late.png', origin: 'late', width: 100 }); + + await expect(pending).resolves.toBe('stale'); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toMatchObject({ hasPreview: false }); + h.engine.lifecycle.dispose(); + }); + + it('lets Escape cancel a gesture before a later Escape cancels the operation', async () => { + const h = await createSamHarness(); + getCanvasOperations(h.engine).startSelectObject('source'); + h.overlay.fire('pointerdown', pointerAt(5, 5)); + h.overlay.fire('pointermove', pointerAt(15, 15)); + h.overlay.fire('pointercancel', pointerAt(15, 15)); + + h.engine.tools.handleEscapePriority({ gestureWasActive: true }); + expect(getCanvasOperations(h.engine).stores.samSession.get()).not.toBeNull(); + + h.engine.tools.handleEscapePriority({ gestureWasActive: false }); + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + h.engine.lifecycle.dispose(); + }); + + it('keeps the Select Object operation active while Space temporarily switches to view', async () => { + const h = await createSamHarness(); + getCanvasOperations(h.engine).startSelectObject('source'); + h.overlay.fire('pointerenter', {}); + + h.fireKey('keydown', 'Space', ' '); + + expect(h.engine.stores.activeTool.get()).toBe('view'); + expect(getCanvasOperations(h.engine).controller.getSnapshot()).toMatchObject({ status: 'active' }); + h.fireKey('keyup', 'Space', ' '); + expect(h.engine.stores.activeTool.get()).toBe('sam'); + h.engine.lifecycle.dispose(); + }); + + it('does not restore sessionless sam when Escape closes the session during a Space hold', async () => { + const h = await createSamHarness(); + getCanvasOperations(h.engine).startSelectObject('source'); + h.overlay.fire('pointerenter', {}); + h.fireKey('keydown', 'Space', ' '); + expect(h.engine.stores.activeTool.get()).toBe('view'); + + h.fireKey('keydown', 'Escape', 'Escape'); + h.fireKey('keyup', 'Space', ' '); + + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + expect(h.engine.stores.activeTool.get()).toBe('view'); + h.engine.lifecycle.dispose(); + }); + + it('does not restore sessionless sam when source invalidation closes the session during a Space hold', async () => { + const h = await createSamHarness(); + getCanvasOperations(h.engine).startSelectObject('source'); + h.overlay.fire('pointerenter', {}); + h.fireKey('keydown', 'Space', ' '); + + h.setDocument({ + ...h.engine.document.getDocument()!, + layers: [{ ...h.engine.document.getDocument()!.layers[0]!, opacity: 0.5 }], + }); + h.fireKey('keyup', 'Space', ' '); + + expect(getCanvasOperations(h.engine).stores.samSession.get()).toBeNull(); + expect(h.engine.stores.activeTool.get()).toBe('view'); + h.engine.lifecycle.dispose(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts new file mode 100644 index 00000000000..05ca1979db3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts @@ -0,0 +1,3420 @@ +/** + * `CanvasEngine`: the per-project imperative heart of the canvas. + * + * One engine instance exists per project (managed by `engineRegistry.ts`) and + * shared by the canvas and layers widgets. It owns interaction, pixels, and + * rendering; the reducer owns the document. The document flows in one way + * through the {@link createDocumentMirror | document mirror}; the engine never + * dispatches from pointer input except for the single gesture-start + * `addCanvasLayer` a paint tool emits when auto-creating a layer. + * + * Responsibilities: + * - Hold the {@link Viewport} (pan/zoom) and the transient {@link EngineStores} + * React subscribes to. + * - `attach`/`detach` bind two stacked canvases (composited document + overlay) + * as render targets, wire pointer/wheel/key listeners, and drive the + * scheduler; `resize` sizes the backing stores to `css × min(dpr, 2)`. + * - Route normalized pointer/wheel input to the active {@link Tool} via the + * pointer pipeline and wheel handler (middle-mouse pan, space/alt temp tools, + * plain-wheel zoom, ctrl+wheel brush-size step), and relay committed strokes + * to `onStrokeCommitted` subscribers. + * - Orchestrate rasterization: ensure a raster cache per visible layer, compose + * the document, and draw the overlay each scheduled frame. + * + * DOM APIs are confined to `attach`/`detach`/`resize` and the DOM raster + * backend, so importing this module stays node-safe. Zero React imports. + */ + +import type { + CanvasHistoryCapability, + CanvasEditCapability, + CanvasCompositeExecutorDeps, + CanvasDocumentCapability, + CanvasDocumentSnapshot, + CanvasExportCapability, + CanvasRasterSnapshot, + CaptureRasterSnapshotResult, + CanvasLifecycleCapability, + CanvasLayerCapability, + CanvasPreviewCapability, + CanvasSelectionCapability, + CanvasSurfaceCapability, + CanvasToolCapability, + CanvasViewportCapability, + ExportBakedLayerPixelsOptions, + ExportLayerPixelsOptions, + LayerExportGuard, + PsdExportResult, +} from '@workbench/canvas-engine/api'; +export type { + CommitGeneratedImageOptions, + CommitGeneratedImageResult, + CommitStagedImageOptions, + CommitStagedImageResult, + ExportBakedLayerBlobResult, + ExportBakedLayerPixelsOptions, + ExportLayerPixelsOptions, + GeneratedImageTarget, + LayerExportGuard, + LayerThumbnailRequestResult, + PsdExportResult, + ReplaceSelectionFromImageResult, +} from '@workbench/canvas-engine/api'; +export type { + CommitMaskImageResult, + CommitMaskImageResultOptions, + MaskImageResultTarget, +} from '@workbench/canvas-engine/controllers/maskResultController'; +export type { + CommitRasterFilterOptions, + CommitRasterFilterResult, +} from '@workbench/canvas-engine/controllers/filterResultController'; +import type { CanvasApplicationHost, SelectObjectStartContext } from '@workbench/canvas-engine/applicationHost'; +import type { CreatePath2D } from '@workbench/canvas-engine/freehand'; +import type { FontLoadApi } from '@workbench/canvas-engine/render/fontLoader'; +import type { LayerCacheEntry, LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { OverlayCursor } from '@workbench/canvas-engine/render/overlayRenderer'; +import type { RenderScheduler } from '@workbench/canvas-engine/render/scheduler'; +import type { SamVisualInput } from '@workbench/canvas-engine/samInteraction'; +import type { Rect, RenderFlags, ToolId, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutationPort } from '@workbench/canvasProjectMutationPort'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { + CanvasImageRef, + CanvasDocumentContractV2, + CanvasLayerContract, + CanvasLayerSourceContract, +} from '@workbench/types'; + +import { ControlPixelController } from '@workbench/canvas-engine/controllers/controlPixelController'; +import { EditingController } from '@workbench/canvas-engine/controllers/editingController'; +import { + FilterResultController, + type CommitRasterFilterOptions, + type CommitRasterFilterResult, +} from '@workbench/canvas-engine/controllers/filterResultController'; +import { GeneratedResultController } from '@workbench/canvas-engine/controllers/generatedResultController'; +import { HistoryController } from '@workbench/canvas-engine/controllers/historyController'; +import { InteractionController } from '@workbench/canvas-engine/controllers/interactionController'; +import { LayerController } from '@workbench/canvas-engine/controllers/layerController'; +import { LayerMutationController } from '@workbench/canvas-engine/controllers/layerMutationController'; +import { + MaskResultController, + type CommitMaskImageResult, + type CommitMaskImageResultOptions, +} from '@workbench/canvas-engine/controllers/maskResultController'; +import { PersistenceController } from '@workbench/canvas-engine/controllers/persistenceController'; +import { PsdExportController } from '@workbench/canvas-engine/controllers/psdExportController'; +import { RasterController, type RasterizationJob } from '@workbench/canvas-engine/controllers/rasterController'; +import { RasterExportController } from '@workbench/canvas-engine/controllers/rasterExportController'; +import { RenderController } from '@workbench/canvas-engine/controllers/renderController'; +import { StagedResultController } from '@workbench/canvas-engine/controllers/stagedResultController'; +import { StructuralLayerController } from '@workbench/canvas-engine/controllers/structuralLayerController'; +import { createCanvasDiagnostics, type CanvasDiagnosticsSnapshot } from '@workbench/canvas-engine/diagnostics'; +import { createEngineStores, type EngineStores, type TextToolOptions } from '@workbench/canvas-engine/engineStores'; +import { + exportRasterComposite as exportRasterCompositeWithDeps, + RasterCompositeOverBudgetError, + type RasterCompositeExportRequest, + type RasterCompositeExportSnapshot, +} from '@workbench/canvas-engine/exportRasterComposite'; +import { LayerFilterOutputDimensionError } from '@workbench/canvas-engine/filterError'; +import { createPointerPipeline, type PointerPipeline } from '@workbench/canvas-engine/input/pointerPipeline'; +import { createWheelHandler } from '@workbench/canvas-engine/input/wheel'; +import { fromTRS } from '@workbench/canvas-engine/math/mat2d'; +import { isEmpty, roundOut, transformBounds, union } from '@workbench/canvas-engine/math/rect'; +import { + compositeDocument, + createCheckerboardTile, + shouldSmoothAtZoom, +} from '@workbench/canvas-engine/render/compositor'; +import { createFontLoader, domFontLoadApi } from '@workbench/canvas-engine/render/fontLoader'; +import { calculateActiveFrameLayerIds } from '@workbench/canvas-engine/render/frameDemand'; +import { createMaskPatternTile } from '@workbench/canvas-engine/render/maskFill'; +import { renderOverlay } from '@workbench/canvas-engine/render/overlayRenderer'; +import { createDomRasterBackend, type RasterBackend, type RasterSurface } from '@workbench/canvas-engine/render/raster'; +import { rasterizeSource, type ImageResolver, type RasterizeDeps } from '@workbench/canvas-engine/render/rasterizers'; +import { textFontString } from '@workbench/canvas-engine/render/rasterizers/textRasterizer'; +import { enforceSurfaceBudget } from '@workbench/canvas-engine/render/surfaceBudget'; +import { getLayerThumbnailDisplayKey } from '@workbench/canvas-engine/render/thumbnail'; +import { ANTS_STEP_PX, createAntsAnimator, type AntsAnimator } from '@workbench/canvas-engine/selection/marchingAnts'; +import { createBboxTool } from '@workbench/canvas-engine/tools/bboxTool'; +import { createBrushTool } from '@workbench/canvas-engine/tools/brushTool'; +import { createColorPickerTool } from '@workbench/canvas-engine/tools/colorPickerTool'; +import { createEraserTool } from '@workbench/canvas-engine/tools/eraserTool'; +import { createGradientTool } from '@workbench/canvas-engine/tools/gradientTool'; +import { createLassoTool } from '@workbench/canvas-engine/tools/lassoTool'; +import { hittableLayerRect, layerOutlineCorners, topLayerAt } from '@workbench/canvas-engine/tools/moveHitTest'; +import { createMoveTool } from '@workbench/canvas-engine/tools/moveTool'; +import { stepBrushSize } from '@workbench/canvas-engine/tools/paintConstants'; +import { createSamTool } from '@workbench/canvas-engine/tools/samTool'; +import { createShapeTool } from '@workbench/canvas-engine/tools/shapeTool'; +import { createTextTool } from '@workbench/canvas-engine/tools/textTool'; +import { createTransformTool } from '@workbench/canvas-engine/tools/transformTool'; +import { type LayerTransform, transformOverlayGeometry } from '@workbench/canvas-engine/transform/transformMath'; +import { createViewport, MAX_DPR, type Viewport } from '@workbench/canvas-engine/viewport'; + +import type { HistoryEntry } from './history/history'; +import type { StrokeCommittedEvent, Tool, ToolContext } from './tools/tool'; + +import { createBitmapStore, type BitmapStore } from './document/bitmapStore'; +import { createDocumentMirror, type DocumentMirror } from './document/documentMirror'; +import { getSourceBounds, getSourceContentRect, isRenderableLayer, renderableSourceOf } from './document/sources'; +import { createImagePatchEntry, type ImagePatchApply } from './history/imagePatch'; +import { createViewTool } from './tools/viewTool'; + +/** + * The input to {@link CanvasEnginePreviewCapability.setStagedPreview}: either a persisted image + * (decoded via the engine's `imageResolver`, sized to the decoded pixels — the + * final staged candidate, optionally with candidate-specific placement) or an + * inline data-URL with explicit document-space dimensions (a live + * denoise-progress frame, scaled to fill those dims at the current bbox). + */ +export interface StagedPreviewPlacement extends Rect { + opacity: number; +} + +export type StagedPreviewInput = + | { imageName: string; placement?: StagedPreviewPlacement } + | { dataUrl: string; width: number; height: number }; + +export interface FilterPreviewInput { + imageName: string; + rect: Rect; + filterType?: string; +} + +/** + * Result of {@link CanvasEngineLayerCapability.mergeVisibleRasterLayers}: `'merged'` when a new + * composite layer was inserted, `'not-ready'` when a contributor could not be + * rasterized consistently, `'busy'` when another edit owns the document, and + * `'nothing'` when fewer than two visible rasters have content. + */ +export type MergeVisibleResult = 'merged' | 'not-ready' | 'busy' | 'nothing'; + +export type BooleanRasterOperation = 'intersect' | 'cutout' | 'cutaway' | 'exclude'; +export type BooleanRasterResult = 'merged' | 'missing' | 'unsupported' | 'not-ready' | 'busy' | 'empty'; + +export type ExtractMaskedAreaResult = + | { status: 'extracted'; layerId: string } + | { status: 'missing' | 'unsupported' | 'not-ready' | 'busy' | 'empty' }; + +export type CropLayerResult = + | { status: 'cropped' } + | { status: 'missing' | 'locked' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' | 'busy' } + | { status: 'failed'; message: string }; + +/** + * Result of {@link CanvasEngineExportCapability.exportRasterLayersToPsd}: `'exported'` on + * success, `'nothing'` when there are no raster layers with content, `'too-large'` + * when the union bounds exceed the PSD dimension cap, and `'not-ready'` when a + * participant's cache is still decoding (nothing exported — surface feedback). + */ +/** Opaque snapshot identity carried through async layer operations. */ +export type ExportLayerPixelsResult = + | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' | 'aborted' }; + +export type ExportBakedLayerPixelsResult = ExportLayerPixelsResult; + +/** Runs every teardown step, then rethrows the first failure after cleanup is terminal. */ +const createCleanupAccumulator = (): { run: (step: () => void) => void; throwIfFailed: () => void } => { + let firstError: unknown; + let hasFailed = false; + return { + run: (step) => { + try { + step(); + } catch (error) { + if (!hasFailed) { + firstError = error; + hasFailed = true; + } + } + }, + throwIfFailed: () => { + if (hasFailed) { + throw firstError instanceof Error ? firstError : new Error(String(firstError)); + } + }, + }; +}; + +/** Structural equality for JSON-safe canvas contracts (including synthetic mask paint sources). */ +const isDeeplyEqual = (left: unknown, right: unknown): boolean => { + if (Object.is(left, right)) { + return true; + } + if (typeof left !== 'object' || left === null || typeof right !== 'object' || right === null) { + return false; + } + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => isDeeplyEqual(value, right[index])) + ); + } + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord); + const rightKeys = Object.keys(rightRecord); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(rightRecord, key) && isDeeplyEqual(leftRecord[key], rightRecord[key]) + ) + ); +}; + +export interface CanvasEngineErrorReport { + area: 'canvas-engine'; + context: { error: string; layerId: string }; + message: 'Layer thumbnail rasterization failed' | 'Bitmap persistence failed'; + namespace: 'canvas'; + projectId: string; +} + +/** Options for {@link createCanvasEngine}. */ +export interface CanvasEngineOptions { + projectId: string; + mutationPort: CanvasProjectMutationPort; + /** Persists encoded engine-owned bitmaps. Application networking stays outside the core. */ + uploadImage(blob: Blob): Promise<{ height: number; imageName: string; width: number }>; + /** Supplies the currently selected model base for core-created control layer contracts. */ + getMainModelBase?: () => string | null; + /** Reports structured engine failures without exposing the global workbench dispatcher. */ + reportError(report: CanvasEngineErrorReport): void; + /** Raster surface/bitmap factory. Defaults to the DOM backend. */ + backend?: RasterBackend; + /** Resolves persisted image assets to blobs for decoding. */ + imageResolver: ImageResolver; + /** + * Overrides the paint-persistence store. Defaults to a real {@link createBitmapStore} + * wired to the layer cache and the upload backend. Tests inject a fake to + * observe dirty-marking / avoid network uploads. + */ + bitmapStore?: BitmapStore; + /** + * Overrides the web-font readiness api used to re-rasterize text layers once a + * pending font loads. Defaults to the browser's `document.fonts` (or a no-op + * in node). Tests inject a fake to drive the load without a real FontFaceSet. + */ + fonts?: FontLoadApi | null; + /** Enables deterministic raster/render counters. Disabled by default. */ + enableDiagnostics?: boolean; +} + +export interface CanvasEngineToolCapability extends CanvasToolCapability { + contextMenuLayerIdAt(screenPoint: Vec2): string | null; + handleEscapePriority(options: { gestureWasActive: boolean }): void; + onStrokeCommitted(listener: (event: StrokeCommittedEvent) => void): () => void; + setInteractionLocked(locked: boolean): void; +} + +export interface CanvasEngineLayerCapability extends CanvasLayerCapability { + applyTransform(): void; + booleanMergeRasterLayers(upperLayerId: string, operation: BooleanRasterOperation): Promise; + cancelTextEdit(): void; + cancelTransform(): void; + clearMask(layerId: string): boolean; + commitLayerConversion(label: string, expectedLiveLayer: CanvasLayerContract, after: CanvasLayerContract): boolean; + commitLayerCopy(label: string, sourceLayerId: string, layer: CanvasLayerContract, index: number): boolean; + commitMaskImageResult(options: CommitMaskImageResultOptions): Promise; + commitOpenTextSession(): boolean; + commitRasterFilterResult(options: CommitRasterFilterOptions): Promise; + commitTextEdit(content: string, styleChanges?: Partial): void; + copyLayerToRaster(layerId: string): Promise; + cropLayerToBbox(layerId: string): Promise; + mergeLayerDown(upperLayerId: string): boolean; + mergeVisibleRasterLayers(): Promise; + nudgeSelectedLayer(dx: number, dy: number): void; + openTextCreate(docPoint: Vec2): void; + openTextEdit(layerId: string): void; + rasterizeLayer(layerId: string): boolean; + setTextEditContentReader(reader: (() => string) | null): void; + updateTextEditStyle(patch: Partial): void; + updateTransformSession(transform: LayerTransform): void; +} + +export interface CanvasEngineExportCapability extends CanvasExportCapability { + exportBakedLayerPixels( + layerId: string, + options?: ExportBakedLayerPixelsOptions + ): Promise; + exportLayerPixels(layerId: string, options?: ExportLayerPixelsOptions): Promise; + exportRasterLayersToPsd(fileName: string): Promise; + extractMaskedArea(maskLayerId: string): Promise; +} + +export interface CanvasEngineSelectionCapability extends CanvasSelectionCapability {} + +export interface CanvasEnginePreviewCapability extends CanvasPreviewCapability { + setGuardedFilterPreview( + layerId: string, + input: FilterPreviewInput, + guard: LayerExportGuard + ): Promise<'shown' | 'missing' | 'stale'>; + setStagedPreview(input: StagedPreviewInput | null): void; +} + +export interface CanvasDiagnosticsCapability { + clearCaches(): Promise; + getDiagnostics(): Readonly; + logDebugInfo(): void; +} + +/** The public engine handle. */ +export interface CanvasEngine { + readonly projectId: string; + readonly surface: CanvasSurfaceCapability; + readonly viewport: CanvasViewportCapability; + readonly tools: CanvasEngineToolCapability; + readonly history: CanvasHistoryCapability; + readonly lifecycle: CanvasLifecycleCapability; + readonly layers: CanvasEngineLayerCapability; + readonly previews: CanvasEnginePreviewCapability; + readonly selection: CanvasEngineSelectionCapability; + readonly edits: CanvasEditCapability; + readonly document: CanvasDocumentCapability; + readonly exports: CanvasEngineExportCapability; + readonly diagnostics: CanvasDiagnosticsCapability; + /** The transient stores React subscribes to. */ + readonly stores: EngineStores; +} + +export interface CanvasEngineCoreComposition { + readonly engine: CanvasEngine; + readonly applicationHost: CanvasApplicationHost; +} + +/** + * Decodes a `data:` URL to a `Blob` without a DOM (`atob`/`Blob`/`Uint8Array` + * are all node-safe), so the staged-progress decode path runs in vitest through + * the injected raster backend just like the imageName path runs through the + * injected resolver. + */ +const dataUrlToBlob = (dataUrl: string): Blob => { + const commaIndex = dataUrl.indexOf(','); + const header = commaIndex >= 0 ? dataUrl.slice(0, commaIndex) : ''; + const data = commaIndex >= 0 ? dataUrl.slice(commaIndex + 1) : dataUrl; + const mime = /^data:([^;,]+)/i.exec(header)?.[1] ?? 'application/octet-stream'; + if (/;base64/i.test(header)) { + const binary = atob(data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new Blob([bytes], { type: mime }); + } + return new Blob([decodeURIComponent(data)], { type: mime }); +}; + +const sourceImageName = (source: CanvasLayerSourceContract): string | null => { + if (source.type === 'image') { + return source.image.imageName; + } + if (source.type === 'paint') { + return source.bitmap?.imageName ?? null; + } + return null; +}; + +/** The image name a layer's source references, if any (raster/control source or mask bitmap). */ +const layerImageName = (layer: CanvasLayerContract): string | null => { + const source = renderableSourceOf(layer); + return source ? sourceImageName(source) : null; +}; + +/** Mints a fresh layer id for engine-created paint layers. */ +const createLayerId = (): string => `layer-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +const createEventId = (): string => `event-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +const clearSurface = (surface: RasterSurface): void => { + surface.ctx.setTransform(1, 0, 0, 1, 0, 0); + surface.ctx.clearRect(0, 0, surface.width, surface.height); +}; + +/** Creates a per-project canvas engine. */ +export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreComposition => { + const { imageResolver, mutationPort, projectId } = opts; + const reportError = (message: CanvasEngineErrorReport['message'], layerId: string, error: unknown): void => + opts.reportError({ + area: 'canvas-engine', + context: { error: error instanceof Error ? error.message : String(error), layerId }, + message, + namespace: 'canvas', + projectId, + }); + const backend = opts.backend ?? createDomRasterBackend(); + const diagnostics = createCanvasDiagnostics(opts.enableDiagnostics); + + const viewport = createViewport(); + const rasterController = new RasterController({ + backend, + diagnostics, + getDocument: () => mirror.getDocument(), + getLayerImageName: layerImageName, + imageResolver, + onVersionChange: (layerId) => editingController?.invalidateLayer(layerId), + }); + const layerCache = rasterController.layers; + const stores = createEngineStores(); + // Web-font readiness for text layers: re-rasterizes a text layer once its font + // resolves (a no-op in node / when `document.fonts` is absent). `undefined` + // opts.fonts falls back to the DOM api; an explicit `null` forces the no-op. + const fontLoader = createFontLoader(opts.fonts === undefined ? domFontLoadApi() : opts.fonts); + + const tools = new Map([ + ['view', createViewTool()], + ['brush', createBrushTool()], + ['eraser', createEraserTool()], + ['move', createMoveTool()], + ['transform', createTransformTool()], + ['bbox', createBboxTool()], + ['colorPicker', createColorPickerTool()], + ['lasso', createLassoTool()], + ['shape', createShapeTool()], + ['gradient', createGradientTool()], + ['text', createTextTool()], + ['sam', createSamTool()], + ]); + let interactionLocked = false; + + // Transient per-layer transform overrides driving the move/transform drag + // preview (compositor + overlay read at render time; the mirror stays untouched). + // The move tool sets only x/y; the transform tool sets the full transform. + const transformOverrides = new Map< + string, + { x: number; y: number; scaleX?: number; scaleY?: number; rotation?: number } + >(); + + const cancelLayerRasterization = (layerId: string): void => rasterController.cancelRasterization(layerId); + const cancelAllLayerRasterizations = (): void => rasterController.cancelAllRasterization(); + + let disposed = false; + const activeRasterSnapshots = new Set(); + let lifecycleState: 'active' | 'cooling' | 'cool' | 'disposed' = 'active'; + let lifecycleGeneration = 0; + let cooldownPromise: Promise<'cooled' | 'dirty'> | null = null; + + // The brush/eraser cursor ring, drawn on the overlay (set by the active tool). + let overlayCursor: OverlayCursor | null = null; + + // The transparency checkerboard pattern tile, built once (lazily) through the + // raster backend and reused each frame (see `createCheckerboardTile`). It is + // rebuilt only when the fed checker colors change (theme/color-mode switch), + // signalled by nulling it in the `checkerColors` subscription below. + let checkerboardTile: RasterSurface | null = null; + const getCheckerboardTile = (): RasterSurface => { + checkerboardTile ??= createCheckerboardTile(backend, stores.checkerColors.get()); + return checkerboardTile; + }; + + // Cached mask fill pattern tiles, keyed by `style:color` (a solid style has no + // tile → cached as `null`). Built lazily through the backend seam like the + // checkerboard and reused each frame by the compositor's mask colorize path. + const maskPatternTiles = new Map(); + const getMaskPatternTile = (style: string, color: string): RasterSurface | null => { + const key = `${style}:${color}`; + if (!maskPatternTiles.has(key)) { + maskPatternTiles.set( + key, + createMaskPatternTile(backend, style as Parameters[1], color) + ); + } + return maskPatternTiles.get(key) ?? null; + }; + + // Memoized adjusted surfaces for raster layers carrying brightness/contrast/ + // saturation/curves — rebuilt only when a layer's cache version or its + // adjustments change (never per frame). Reused each frame by the compositor. + const derivedSurfaceCache = rasterController.derived; + const deleteDerivedSurfaces = (layerId: string): void => rasterController.deleteDerivedSurfaces(layerId); + const getAdjustedSurface = (layer: CanvasLayerContract, entry: LayerCacheEntry): RasterSurface | null => + rasterController.getAdjustedSurface(layer, entry); + + // Completed-stroke subscribers (persistence P2.2, history P2.3). + const strokeListeners = new Set<(event: StrokeCommittedEvent) => void>(); + const toolChangeListeners = new Set<(change: { from: string; to: string; temporary: boolean }) => void>(); + let samInputHandler: ((input: SamVisualInput) => void) | null = null; + let applicationEscapeHandler: ((gestureWasActive: boolean) => boolean) | null = null; + + /** + * A layer's CURRENT document source (raster/control layers only), or `null` + * if the layer doesn't exist / isn't a source-bearing layer. Shared by the + * bitmap store's source-type flush guard and `onLayersChanged`'s self-echo + * check below — both need the same "what does this id currently point at" + * lookup. Reads `mirror` by closure; safe because neither caller invokes it + * before `mirror` is assigned further down. + */ + const getLayerSourceById = (layerId: string): CanvasLayerSourceContract | null => { + const doc = mirror.getDocument(); + const layer = doc?.layers.find((candidate) => candidate.id === layerId); + // Masks expose their alpha bitmap as a synthetic `paint` source so the bitmap + // store's source-type/redundant-dispatch guards and the mirror's self-echo + // check work uniformly across paint layers and masks. + return layer ? renderableSourceOf(layer) : null; + }; + + const getAuthoritativeLayerSourceById = (layerId: string): CanvasLayerSourceContract | null => { + const layer = mutationPort.getCanvasState()?.document.layers.find((candidate) => candidate.id === layerId); + return layer ? renderableSourceOf(layer) : null; + }; + + /** + * Applies a persisted bitmap ref + offset to a layer's document contract — the + * bitmap store's single swap-on-success dispatch. Raster/control layers take a + * `paint` source (`updateCanvasLayerSource`); mask layers take their `mask` + * bitmap + offset (`updateCanvasLayerConfig`, preserving the fill). The self-echo + * `lastApplied` name the store records covers both, so a mask flush round-tripping + * back through the mirror is skipped for re-rasterization exactly like a paint one. + */ + const dispatchLayerBitmap = (layerId: string, bitmap: CanvasImageRef, offset: { x: number; y: number }): boolean => { + const doc = mirror.getDocument(); + const layer = doc?.layers.find((candidate) => candidate.id === layerId); + if (!layer) { + return false; + } + if (layer.type === 'raster' || layer.type === 'control') { + return mutationPort.dispatch({ + id: layerId, + source: { bitmap, offset, type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + } else if (layer.type === 'inpaint_mask' || layer.type === 'regional_guidance') { + return mutationPort.dispatch({ + config: { layerType: layer.type, mask: { bitmap, offset } }, + id: layerId, + type: 'updateCanvasLayerConfig', + }); + } + return false; + }; + + // Paint persistence: debounced PNG encode → SHA-256 dedupe → upload → a single + // swap-on-success `updateCanvasLayerSource` (paint) / `updateCanvasLayerConfig` + // (mask). Wired to committed strokes below. + const bitmapStore: BitmapStore = + opts.bitmapStore ?? + createBitmapStore({ + dispatch: (action) => mutationPort.dispatch(action), + dispatchBitmap: (layerId, bitmap, offset) => dispatchLayerBitmap(layerId, bitmap, offset), + encodeSurface: (surface) => backend.encodeSurface(surface), + getAuthoritativeLayerSource: getAuthoritativeLayerSourceById, + getLayerSource: getLayerSourceById, + getLayerSurface: (layerId) => { + const entry = layerCache.get(layerId); + // Content-sized: skip empty (zero-rect) caches — nothing to persist — and + // carry the cache's content-rect origin as the paint source offset. + if (!entry || entry.rect.width <= 0 || entry.rect.height <= 0) { + return null; + } + return { offset: { x: entry.rect.x, y: entry.rect.y }, surface: entry.surface }; + }, + onError: (error, layerId) => reportError('Bitmap persistence failed', layerId, error), + uploadImage: (blob) => opts.uploadImage(blob), + }); + const persistenceController = new PersistenceController(bitmapStore); + + // Engine-owned canvas history (paint pixel patches + structural patches). + // Project-level undo deliberately no longer covers the canvas (Phase 0). + const historyController = new HistoryController({ + canEdit: () => canEditDocument(), + canRedoStore: stores.canRedo, + canUndoStore: stores.canUndo, + endBurst: () => endNudgeBurst(), + isGestureActive: () => pipeline.isGestureActive(), + }); + const history = historyController.history; + // Direct pixel writes do not replace the reducer canvas object. Snapshot + // freshness therefore also binds to this engine-local content epoch. + let rasterContentEpoch = 0; + let controlPixelController: ControlPixelController | null = null; + const cancelOpenControlPixelEdit = (): void => { + controlPixelController?.cancel(); + }; + + const structuralController = new StructuralLayerController({ + canEdit: () => canEditDocument(), + dispatch: (action) => mutationPort.dispatch(action), + getDocument: () => mirror.getDocument(), + history, + isGestureActive: () => pipeline.isGestureActive(), + }); + const endNudgeBurst = (): void => structuralController.endBurst(); + const commitStructural = (label: string, forward: CanvasProjectMutation, inverse: CanvasProjectMutation): boolean => + structuralController.commit(label, forward, inverse); + const nudgeSelectedLayer = (dx: number, dy: number): void => structuralController.nudge(dx, dy); + + /** + * The pixel-write bridge shared by undo and redo: put the patch's pixels back + * into the layer's live cache surface, propagate the edit, and re-persist. + * + * ## Undo ↔ bitmap-store convergence (P2.2 reviewer note) + * + * Undo writes the OLD pixels into the cache and marks the layer dirty while an + * upload of the NEW pixels may still be in flight. The sequence converges: + * + * 1. The in-flight upload finishes and dispatches `updateCanvasLayerSource` + * with the NEW bitmap ref. Because the store recorded that name in + * `lastApplied` before dispatching, the engine's mirror sees it as a + * self-echo ({@link BitmapStore.isSelfEcho}) and does NOT re-rasterize — so + * the cache keeps the OLD pixels this undo just wrote (never clobbered). + * The contract now *transiently* points at the NEW ref. + * 2. This `markLayerDirty` schedules a follow-up flush. The bitmap store + * serializes per layer, so it runs after the in-flight upload settles. It + * encodes the cache (now the OLD pixels), hashes it, and the content-hash + * dedupe reuses the OLD pixels' already-uploaded image name — no re-upload. + * 3. That flush dispatches the OLD ref, moving `lastApplied` back and pointing + * the contract at the OLD pixels: cache and contract have re-converged. + */ + const applyImagePatch: ImagePatchApply = (layerId, rect, pixels) => { + if (!layerCache.get(layerId)) { + // The layer's cache is gone (removed/evicted); nothing to restore into. + return; + } + // The patch `rect` is in LAYER-LOCAL coordinates (stable across cache growth). + // Grow the cache to cover it before writing — an undo/redo whose region falls + // outside the current (possibly shrunk-since) extent must re-expand the cache + // rather than write out of bounds. `growToRect` preserves existing pixels. + const entry = layerCache.growToRect(layerId, rect); + // Match the paint hot path: write pixels straight into the live cache surface + // (no re-rasterize from source), translated to the surface's local origin, + // then bump version/thumbnail and recomposite. + entry.surface.ctx.putImageData(pixels, rect.x - entry.rect.x, rect.y - entry.rect.y); + notifyLayerPainted(layerId); + // Re-persist the restored pixels (converges the contract ref; see above). + bitmapStore.markLayerDirty(layerId); + }; + + /** + * REPLACES a layer's whole cache with a fresh content-sized surface holding + * `pixels` placed at `rect` (layer-local). Unlike {@link applyImagePatch} (which + * grows + overlays a dirty region into a persistent cache), this swaps the entire + * cache extent — used by the transform bake's undo/redo, where the pre- and + * post-bake states occupy DIFFERENT rects (an overlay would leave stale pixels + * outside the smaller rect). Shields the pixels from the async rasterize pass and + * re-persists through the normal dirty path. + */ + const restoreLayerCache = (layerId: string, rect: Rect, pixels: ImageData): void => { + layerCache.delete(layerId); + deleteDerivedSurfaces(layerId); + const entry = layerCache.getOrCreateRect(layerId, rect); + if (rect.width > 0 && rect.height > 0) { + entry.surface.ctx.putImageData(pixels, 0, 0); + } + entry.stale = false; + notifyLayerPainted(layerId); + bitmapStore.markLayerDirty(layerId); + }; + + const createPath2DImpl: CreatePath2D = (d) => new Path2D(d); + + /** Bumps a layer's cache version after a direct paint (pixels stay fresh) and recomposites. */ + const notifyLayerPainted = (layerId: string): void => { + const entry = layerCache.publishPixels(layerId); + if (entry) { + rasterContentEpoch += 1; + stores.thumbnailVersion.set(layerId, entry.version); + stores.thumbnailStatus.set(layerId, 'ready'); + } + if (renderController.previews.hasFilter(layerId)) { + clearFilterPreview(layerId); + } + scheduler.invalidate({ layers: [layerId] }); + }; + + /** Invalidates cached pixels and drops only previews tied to that exact cache version. */ + const invalidateLayerCache = (layerId: string): void => { + cancelLayerRasterization(layerId); + layerCache.invalidate(layerId); + stores.thumbnailStatus.delete(layerId); + if (renderController.previews.hasFilter(layerId)) { + clearFilterPreview(layerId); + } + }; + + /** + * A composed history entry for a stroke that auto-created its paint layer. + * Undo removes the created layer (its cache is dropped by the mirror, so no + * pixel restore is needed); redo re-adds the layer, recreates a blank cache, + * and re-applies the stroke's `after` pixels. + */ + const createComposedPaintEntry = ( + created: { layer: CanvasLayerContract; index: number }, + event: StrokeCommittedEvent, + label: string + ): HistoryEntry => { + const { afterImageData, dirtyRect, layerId } = event; + const rect: Rect = { height: dirtyRect.height, width: dirtyRect.width, x: dirtyRect.x, y: dirtyRect.y }; + const bytes = event.beforeImageData.data.byteLength + afterImageData.data.byteLength + 256; + return { + bytes, + label, + redo: () => { + mutationPort.dispatch({ index: created.index, layer: created.layer, type: 'addCanvasLayer' }); + // Re-create an EMPTY cache marked fresh so the async rasterize pass can't + // clobber the restored stroke; `applyImagePatch` grows it to the stroke's + // content bounds and writes the `after` pixels. + const entry = layerCache.getOrCreateRect(layerId, { height: 0, width: 0, x: 0, y: 0 }); + entry.stale = false; + applyImagePatch(layerId, rect, afterImageData); + }, + undo: () => { + mutationPort.dispatch({ ids: [layerId], type: 'removeCanvasLayers' }); + }, + }; + }; + + const commitOrdinaryStroke = (event: StrokeCommittedEvent): void => { + // A fresh stroke ends any open nudge-coalescing burst. + endNudgeBurst(); + notifyLayerPainted(event.layerId); + // Persistence first: mark the layer dirty so a debounced upload fires even + // when no external subscriber is attached. + bitmapStore.markLayerDirty(event.layerId); + // Record the edit on the engine-owned history. Guarded against re-entrancy: + // an undo/redo replay routes pixels through `applyImagePatch`, not a fresh + // stroke, so this never fires during apply — the guard is belt-and-braces. + if (!history.isApplying()) { + const label = event.tool === 'eraser' ? 'Eraser stroke' : 'Brush stroke'; + history.push( + event.createdLayer + ? createComposedPaintEntry(event.createdLayer, event, label) + : createImagePatchEntry({ + after: event.afterImageData, + apply: applyImagePatch, + before: event.beforeImageData, + label, + layerId: event.layerId, + rect: event.dirtyRect, + }) + ); + } + for (const listener of strokeListeners) { + listener(event); + } + }; + + // ---- Selection (transient interaction state) + marching ants ------------ + // + // The selection lives on the engine, never the reducer, and is not undoable + // (legacy parity). The lasso tool commits paths through `commitSelection`; the + // mask clips paint strokes and drives fill/erase. Marching ants animate on the + // overlay only (never recomposite — Task-22 gate) while a selection exists and + // the engine is attached. + + let antsPhase = 0; + + const onSelectionChanged = (): void => { + // Selection state is already authoritative before this notification runs. + // Keep each derived UI/render notification independent and best-effort so a + // faulty observer cannot make an applied selection report false failure. + try { + stores.hasSelection.set(selection.hasSelection()); + } catch { + // The scalar store commits before notifying observers. + } + try { + updateAntsAnimation(); + } catch { + // A later selection mutation/attach transition reconciles animation. + } + try { + scheduler.invalidate({ overlay: true }); + } catch { + // The next render invalidation will draw the authoritative selection. + } + }; + + const editingController = new EditingController({ + getDocument: () => mirror.getDocument(), + selection: { + backend, + createPath2D: createPath2DImpl, + getDocumentSize: () => { + const doc = mirror.getDocument(); + return doc ? { height: doc.height, width: doc.width } : null; + }, + onChange: () => onSelectionChanged(), + }, + selectionPixels: { + applyImagePatch, + backend, + beginControlEdit: (layerId) => beginControlPixelEdit(layerId), + canEdit: () => canEditDocument(), + deleteDerived: deleteDerivedSurfaces, + endBurst: () => endNudgeBurst(), + getDocument: () => mirror.getDocument(), + getFillColor: () => stores.brushOptions.get().color, + history, + invalidateLayer: (layerId) => scheduler.invalidate({ layers: [layerId] }), + isGestureActive: () => pipeline.isGestureActive(), + layers: layerCache, + markDirty: (layerId) => bitmapStore.markLayerDirty(layerId), + notifyPainted: notifyLayerPainted, + }, + selectionImage: { + capturePermit: (owner) => captureDocumentEditPermit(owner), + decodeImage: (image, options) => rasterController.decodeImage(image, options), + getDocument: () => mirror.getDocument(), + isGestureActive: () => pipeline.isGestureActive(), + isGuardCurrent: (guard) => isLayerExportGuardCurrent(guard), + isPermitCurrent: (permit) => isDocumentEditPermitCurrent(permit as DocumentEditPermit), + }, + text: { + canEdit: () => canEditDocument(), + commitStructural: (label, forward, inverse) => commitStructural(label, forward, inverse), + createLayerId, + getDocument: () => mirror.getDocument(), + invalidate: (payload) => scheduler.invalidate(payload), + isGestureActive: () => pipeline.isGestureActive(), + options: stores.textOptions, + session: stores.textEditSession, + }, + transform: { + backend, + canEdit: () => canEditDocument(), + dispatch: (action) => mutationPort.dispatch(action), + endBurst: () => endNudgeBurst(), + getCache: (layerId) => layerCache.get(layerId) ?? null, + getDocument: () => mirror.getDocument(), + invalidate: (payload) => scheduler.invalidate(payload), + isGestureActive: () => pipeline.isGestureActive(), + pushHistory: (entry) => history.push(entry), + replaceCache: (layerId, rect, surface) => { + layerCache.delete(layerId); + const target = layerCache.getOrCreateRect(layerId, rect); + target.surface.ctx.drawImage(surface.canvas, 0, 0); + target.stale = false; + notifyLayerPainted(layerId); + bitmapStore.markLayerDirty(layerId); + }, + restoreCache: restoreLayerCache, + session: stores.transformSession, + setOverride: (layerId, transform) => { + if (transform) { + transformOverrides.set(layerId, transform); + } else { + transformOverrides.delete(layerId); + } + }, + }, + }); + const selection = editingController.selection; + + const antsAnimator: AntsAnimator = createAntsAnimator({ + cancelFrame: (handle) => globalThis.cancelAnimationFrame(handle), + now: () => + typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now(), + onStep: () => { + antsPhase += ANTS_STEP_PX; + // Overlay-only: an ants tick never recomposites the document. + scheduler.invalidate({ overlay: true }); + }, + requestFrame: (callback) => globalThis.requestAnimationFrame(callback), + }); + + /** Runs the ants loop only while a selection exists AND render targets are bound. */ + function updateAntsAnimation(): void { + if (!disposed && selection.hasSelection() && renderController.getInputElement()) { + antsAnimator.start(); + } else { + antsAnimator.stop(); + } + } + + const toolContext: ToolContext = { + applyTransform: () => applyTransform(), + backend, + beginControlPixelEdit: (layerId) => beginControlPixelEdit(layerId), + beginTransformSession: (layerId) => beginTransformSession(layerId), + cancelTextEdit: () => cancelTextEdit(), + cancelTransform: () => cancelTransform(), + commitSelection: (commit) => selection.commit(commit), + commitStructural: (label, forward, inverse) => commitStructural(label, forward, inverse), + createLayerId, + createPath2D: createPath2DImpl, + dispatch: (action) => mutationPort.dispatch(action), + emitStrokeCommitted: (event) => commitOrdinaryStroke(event), + getDocument: () => mirror.getDocument(), + getSelectionMask: () => selection.mask(), + invalidate: (payload) => scheduler.invalidate(payload), + layers: layerCache, + notifyLayerPainted, + getSamInteraction: () => stores.samInteraction.get(), + openTextCreate: (docPoint) => openTextCreate(docPoint), + openTextEdit: (layerId) => openTextEdit(layerId), + setLayerTransformOverride: (layerId, override) => { + if (override) { + transformOverrides.set(layerId, override); + } else { + transformOverrides.delete(layerId); + } + scheduler.invalidate({ layers: [layerId], overlay: true }); + }, + setOverlayCursor: (cursor) => { + overlayCursor = cursor; + }, + stores, + updateCursor: () => updateCursor(), + updateSamInput: (input) => samInputHandler?.(input), + updateTransformSession: (transform) => updateTransformSession(transform), + viewport, + }; + + const activeTool = (): Tool | undefined => tools.get(interactionController.getActiveToolId()); + + /** Applies a CSS cursor to the input element, guarded for node stubs without `style`. */ + const applyCursorToInput = (cursor: string): void => { + const style = renderController.getInputElement()?.style; + if (style) { + style.cursor = cursor; + } + }; + + const updateCursor = (): void => { + const cursor = activeTool()?.cursor?.(toolContext) ?? 'default'; + stores.cursor.set(cursor); + // The store write alone never changes the pointer; apply to the DOM directly. + applyCursorToInput(cursor); + }; + + /** + * Resizes the brush/eraser cursor ring in place when the active tool's size + * changes without a pointer event (`[`/`]` hotkeys, ctrl+wheel, or the + * options-bar slider). The ring's radius otherwise stays stale until the next + * pointermove; here we keep its last-known center and just refresh the radius, + * then invalidate the overlay so it redraws immediately. + */ + const refreshBrushCursorRadius = (): void => { + if (!overlayCursor) { + return; + } + let size: number | null = null; + if (interactionController.getActiveToolId() === 'brush') { + size = stores.brushOptions.get().size; + } else if (interactionController.getActiveToolId() === 'eraser') { + size = stores.eraserOptions.get().size; + } + if (size === null) { + return; + } + overlayCursor = { point: overlayCursor.point, radiusDoc: size / 2 }; + scheduler.invalidate({ overlay: true }); + }; + + // ---- Rasterization orchestration --------------------------------------- + + const rasterizeDeps = (doc: CanvasDocumentContractV2, signal?: AbortSignal): RasterizeDeps => ({ + backend, + bitmapPool: rasterController.bitmaps, + documentSize: { height: doc.height, width: doc.width }, + resolver: imageResolver, + signal, + store: layerCache, + }); + + const isSupportedExportSource = (source: CanvasLayerSourceContract): boolean => { + if (source.type === 'shape') { + return source.kind !== 'polygon'; + } + return true; + }; + + const isCurrentRasterizationJob = (layer: CanvasLayerContract): boolean => { + const job = rasterController.getRasterizationJob(layer.id); + const source = renderableSourceOf(layer); + return ( + !!job && + !!source && + job.version === layerCache.version(layer.id) && + job.documentGeneration === rasterController.getDocumentGeneration() && + isDeeplyEqual(job.source, source) + ); + }; + + /** + * Starts (or joins) an isolated rasterization. Pixels land in a scratch + * surface and are copied into the live cache only while this exact job still + * describes the current layer source, cache version, and document. + */ + const getOrStartLayerRasterization = ( + layer: CanvasLayerContract, + document: CanvasDocumentContractV2, + signal?: AbortSignal + ): Promise<'published' | 'stale' | 'error' | 'aborted'> => { + if (signal?.aborted) { + return Promise.resolve('aborted'); + } + if (disposed || mutationPort.getCanvasState() === null) { + return Promise.resolve('stale'); + } + const liveSource = renderableSourceOf(layer); + if (!liveSource || !isSupportedExportSource(liveSource)) { + return Promise.resolve('stale'); + } + + const contentRect = getSourceContentRect(layer, document); + const entry = layerCache.getOrCreateRect(layer.id, contentRect); + const version = entry.version; + const documentGeneration = rasterController.getDocumentGeneration(); + const source = structuredClone(liveSource); + const existing = rasterController.getRasterizationJob(layer.id); + if ( + existing && + existing.version === version && + existing.documentGeneration === documentGeneration && + isDeeplyEqual(existing.source, source) + ) { + if (!signal) { + return existing.promise; + } + const abort = (): void => { + existing.abortedByCaller = true; + existing.controller.abort(signal.reason); + }; + signal.addEventListener('abort', abort, { once: true }); + return existing.promise.finally(() => signal.removeEventListener('abort', abort)); + } + cancelLayerRasterization(layer.id); + + if (source.type === 'text') { + fontLoader.ensure(textFontString(source), () => { + const currentDocument = mirror.getDocument(); + const currentLayer = currentDocument?.layers.find((candidate) => candidate.id === layer.id); + if ( + disposed || + mutationPort.getCanvasState() === null || + !currentLayer || + rasterController.getDocumentGeneration() !== documentGeneration || + layerCache.version(layer.id) !== version || + !isDeeplyEqual(renderableSourceOf(currentLayer), source) + ) { + return; + } + invalidateLayerCache(layer.id); + scheduler.invalidate({ layers: [layer.id] }); + }); + } + + const scratch = backend.createSurface(contentRect.width, contentRect.height); + const controller = new AbortController(); + let settleJob!: (result: 'published' | 'stale' | 'error' | 'aborted') => void; + const promise = new Promise<'published' | 'stale' | 'error' | 'aborted'>((resolve) => { + settleJob = resolve; + }); + const job: RasterizationJob = { + controller, + documentGeneration, + promise, + source, + version, + }; + const abort = (): void => { + job.abortedByCaller = true; + controller.abort(signal?.reason); + }; + signal?.addEventListener('abort', abort, { once: true }); + rasterController.installRasterizationJob(layer.id, job); + let published = false; + void (async () => { + try { + const result = await rasterizeSource(source, rasterizeDeps(document, controller.signal), scratch); + const currentDocument = mirror.getDocument(); + const currentLayer = currentDocument?.layers.find((candidate) => candidate.id === layer.id); + const currentEntry = layerCache.get(layer.id); + if ( + disposed || + mutationPort.getCanvasState() === null || + rasterController.getRasterizationJob(layer.id) !== job || + rasterController.getDocumentGeneration() !== documentGeneration || + !currentLayer || + !currentEntry || + currentEntry.version !== version || + !isDeeplyEqual(renderableSourceOf(currentLayer), source) + ) { + return 'stale'; + } + + if (currentEntry.surface.width !== result.rect.width || currentEntry.surface.height !== result.rect.height) { + currentEntry.surface.resize(result.rect.width, result.rect.height); + } + const ctx = currentEntry.surface.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, result.rect.width, result.rect.height); + if (!isEmpty(result.rect)) { + ctx.drawImage(result.surface.canvas, 0, 0); + } + currentEntry.rect = { ...result.rect }; + const publishedEntry = layerCache.publishPixels(layer.id); + if (!publishedEntry) { + return 'stale'; + } + trackPublishedLayerImage(currentLayer); + published = true; + stores.thumbnailVersion.set(layer.id, publishedEntry.version); + stores.thumbnailStatus.set(layer.id, 'ready'); + scheduler.invalidate({ layers: [layer.id] }); + return 'published'; + } catch (error) { + if (job.abortedByCaller) { + return 'aborted'; + } + const currentDocument = mirror.getDocument(); + const currentLayer = currentDocument?.layers.find((candidate) => candidate.id === layer.id); + if ( + disposed || + mutationPort.getCanvasState() === null || + rasterController.getRasterizationJob(layer.id) !== job || + rasterController.getDocumentGeneration() !== documentGeneration || + !currentLayer || + layerCache.version(layer.id) !== version || + !isDeeplyEqual(renderableSourceOf(currentLayer), source) + ) { + return 'stale'; + } + stores.thumbnailStatus.set(layer.id, 'error'); + try { + reportError('Layer thumbnail rasterization failed', layer.id, error); + } catch { + // Diagnostics must not turn a handled thumbnail failure into a rejection. + } + return 'error'; + } finally { + signal?.removeEventListener('abort', abort); + rasterController.finishRasterizationJob(layer.id, job); + const imageName = sourceImageName(source); + if (!published && imageName) { + releaseBitmapIfUnreferenced(imageName); + } + } + })().then(settleJob, () => settleJob('stale')); + return promise; + }; + + const captureLayerExportGuard = (layer: CanvasLayerContract, entry: LayerCacheEntry): LayerExportGuard => ({ + cacheVersion: entry.version, + documentGeneration: rasterController.getDocumentGeneration(), + layer, + layerId: layer.id, + projectId, + }); + + const isLayerExportGuardCurrent = (guard: LayerExportGuard): boolean => { + if ( + disposed || + guard.projectId !== projectId || + mutationPort.getCanvasState() === null || + guard.documentGeneration !== rasterController.getDocumentGeneration() + ) { + return false; + } + const document = mirror.getDocument(); + const liveLayer = document?.layers.find((candidate) => candidate.id === guard.layerId); + const entry = layerCache.get(guard.layerId); + return !!entry && liveLayer === guard.layer && entry.version === guard.cacheVersion; + }; + + const captureCurrentLayerExportGuard = (layerId: string): LayerExportGuard | null => { + const document = mirror.getDocument(); + const layer = document?.layers.find((candidate) => candidate.id === layerId); + const source = layer ? renderableSourceOf(layer) : null; + const entry = layerCache.get(layerId); + if ( + !layer || + !source || + !isSupportedExportSource(source) || + !entry || + entry.stale || + isCurrentRasterizationJob(layer) || + isEmpty(entry.rect) + ) { + return null; + } + const guard = captureLayerExportGuard(layer, entry); + return isLayerExportGuardCurrent(guard) ? guard : null; + }; + + const documentEditOwner = Symbol('canvas-operation-document-edit-owner'); + type DocumentEditPermit = { epoch: number; owner?: symbol }; + let documentEditEpoch = 0; + let documentEditingLocked = false; + const syncDocumentEditingLock = (): void => { + const nextLocked = stores.documentEditingLocked.get(); + if (nextLocked !== documentEditingLocked) { + documentEditingLocked = nextLocked; + documentEditEpoch += 1; + } + }; + const unsubscribeDocumentEditingLock = stores.documentEditingLocked.subscribe(syncDocumentEditingLock); + const canEditDocument = (owner?: symbol): boolean => + owner === documentEditOwner || !stores.documentEditingLocked.get(); + const captureDocumentEditPermit = (owner?: symbol): DocumentEditPermit | null => + canEditDocument(owner) ? { epoch: documentEditEpoch, owner } : null; + const isDocumentEditPermitCurrent = (permit: DocumentEditPermit): boolean => + permit.owner === documentEditOwner || (!stores.documentEditingLocked.get() && permit.epoch === documentEditEpoch); + + const ensureLayerCaches = (doc: CanvasDocumentContractV2, activeFrameLayerIds: ReadonlySet): void => { + for (const layer of doc.layers) { + // The layer's rasterizable source: a raster/control `source`, or a mask + // layer's alpha bitmap viewed as a paint source (colorized at composite). + const source = renderableSourceOf(layer); + if (!layer.isEnabled || !source || !activeFrameLayerIds.has(layer.id)) { + continue; + } + if (!isRenderableLayer(layer)) { + continue; + } + + // Ensure a cache entry exists WITHOUT resizing an existing one: a paint + // layer's live cache may have grown past its persisted content rect (fresh + // unflushed strokes), so we must never resize it down to the contract size + // here — that would destroy those pixels. The rasterizer (below, guarded by + // `stale`) owns sizing the surface + placing its content rect. + const entry = layerCache.getOrCreateRect(layer.id, getSourceContentRect(layer, doc)); + if (!entry.stale) { + continue; + } + + void getOrStartLayerRasterization(layer, doc); + } + }; + + const hasExportableLayerContent = (layerId: string): boolean => { + const doc = mirror.getDocument(); + const layer = doc?.layers.find((candidate) => candidate.id === layerId); + if (!doc || !layer) { + return false; + } + const source = renderableSourceOf(layer); + if (!source || !isSupportedExportSource(source)) { + return false; + } + if (!isEmpty(getSourceContentRect(layer, doc))) { + return true; + } + // Only paint-backed layers (including masks through renderableSourceOf) can + // have real pixels beyond their persisted source rect. The live cache must + // describe the current source revision and must not be mid-rasterization. + if (source.type !== 'paint') { + return false; + } + const entry = layerCache.get(layerId); + return !!entry && !entry.stale && !isCurrentRasterizationJob(layer) && !isEmpty(entry.rect); + }; + + const rasterExportController = new RasterExportController({ + backend, + captureGuard: captureLayerExportGuard, + getDocument: () => mirror.getDocument(), + getOrStartRasterization: getOrStartLayerRasterization, + isGuardCurrent: isLayerExportGuardCurrent, + isRasterizing: isCurrentRasterizationJob, + isSupportedSource: isSupportedExportSource, + layers: layerCache, + pin: (layerId) => rasterController.memory.pin(layerId, lifecycleGeneration), + reserve: (bytes) => { + rasterController.memory.setBaseBytes(layerCache.byteSize()); + rasterController.memory.setDerivedBytes(derivedSurfaceCache.byteSize()); + return rasterController.memory.reserve(bytes, { generation: lifecycleGeneration, purpose: 'raster-export' }); + }, + }); + const rasterizeLayerPixels = rasterExportController.rasterize.bind(rasterExportController); + const exportBakedLayerPixels = rasterExportController.baked.bind(rasterExportController); + const exportBakedLayerBlob = rasterExportController.blob.bind(rasterExportController); + type StructuralExportLayerPixelsResult = + | Extract + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' }; + const normalizeStructuralExport = async ( + result: Promise + ): Promise => { + const resolved = await result; + if (resolved.status === 'ok') { + return resolved; + } + return { status: resolved.status === 'aborted' ? 'not-ready' : resolved.status }; + }; + const exportBakedLayerPixelsForStructural = ( + layerId: string, + options?: ExportBakedLayerPixelsOptions + ): Promise => normalizeStructuralExport(exportBakedLayerPixels(layerId, options)); + const rasterizeLayerPixelsForStructural = ( + layerId: string, + options?: ExportLayerPixelsOptions + ): Promise => normalizeStructuralExport(rasterizeLayerPixels(layerId, options)); + const rasterizeLayerForThumbnail = async ( + layer: CanvasLayerContract, + document: CanvasDocumentContractV2 + ): Promise<'published' | 'stale' | 'error'> => { + const result = await getOrStartLayerRasterization(layer, document); + return result === 'aborted' ? 'stale' : result; + }; + + const cropLayerToBbox = (layerId: string): Promise => layerController.crop.crop(layerId); + + const copyLayerToRaster = (layerId: string): Promise => layerController.copy.copyToRaster(layerId); + + /** + * Rasterizes a single layer on demand and returns its cache surface plus the + * content rect (layer-local origin/size) those pixels occupy, for the + * composite-for-generation executor. Rasterize-or-throw: a missing layer, a + * non-raster/control layer, or an unsupported source throws a descriptive error + * rather than returning a blank surface, so an invoke can never silently drop a + * contributing layer. Only invoked for layers the pure planner already selected + * (enabled image/paint rasters). + */ + type LayerSurfaceForExportResult = + | { status: 'ok'; surface: RasterSurface; rect: Rect } + | { status: 'aborted' | 'not-ready' | 'over-budget' }; + const getLayerSurfaceForExport = async ( + layerId: string, + signal?: AbortSignal + ): Promise => { + const result = await rasterizeLayerPixels(layerId, { signal }); + if (result.status === 'ok') { + return { rect: result.rect, status: 'ok', surface: result.surface }; + } + if (result.status === 'over-budget') { + return { status: 'over-budget' }; + } + if (result.status === 'aborted') { + return { status: 'aborted' }; + } + return { status: 'not-ready' }; + }; + const requireLayerSurfaceForExport = async (layerId: string): Promise<{ surface: RasterSurface; rect: Rect }> => { + const result = await getLayerSurfaceForExport(layerId); + if (result.status === 'ok') { + return result; + } + if (result.status === 'over-budget') { + throw new RasterCompositeOverBudgetError(); + } + throw new Error(`Cannot rasterize layer ${layerId} for generation: ${result.status}.`); + }; + + const releaseBitmapIfUnreferenced = (imageName: string): void => + rasterController.releaseBitmapIfUnreferenced(imageName); + const trackPublishedLayerImage = (layer: CanvasLayerContract): void => + rasterController.trackPublishedLayerImage(layer); + + const dropLayer = (layerId: string): void => { + // Generation-cancel persistence before the id can be restored by undo/redo. + // A late upload from the removed incarnation must never target a recreated + // paint layer with the same id. + try { + bitmapStore.discardLayer(layerId); + } catch { + // Keep authoritative removal cleanup observer-safe for injected stores. + } + rasterController.dropLayer(layerId); + stores.thumbnailVersion.delete(layerId); + stores.thumbnailStatus.delete(layerId); + }; + + // ---- Staged generation preview ------------------------------------------ + + /** Drops any staged preview and bumps the token so an in-flight decode is discarded. */ + const clearStagedPreview = (): void => { + if (renderController.previews.clearStaged()) { + scheduler.invalidate({ all: true }); + } + }; + + /** Decodes a staged-preview input to a surface (imageName via resolver, dataUrl via the backend seam). */ + const decodeStagedPreview = async ( + input: StagedPreviewInput + ): Promise<{ surface: RasterSurface; width: number; height: number; placement?: StagedPreviewPlacement }> => { + if ('imageName' in input) { + const blob = await imageResolver(input.imageName); + const decoded = await rasterController.decodeBlob(blob); + return { + height: decoded.decodedHeight, + placement: input.placement ? { ...input.placement } : undefined, + surface: decoded.surface, + width: decoded.decodedWidth, + }; + } + const { dataUrl, height, width } = input; + const decoded = await rasterController.decodeBlob(dataUrlToBlob(dataUrl), { height, scale: true, width }); + return { height, surface: decoded.surface, width }; + }; + + const setStagedPreview = (input: StagedPreviewInput | null): void => { + if (input === null) { + clearStagedPreview(); + return; + } + const token = renderController.previews.nextStagedToken(); + decodeStagedPreview(input) + .then((decoded) => { + // A newer set/clear superseded this decode while it was in flight. + if (renderController.previews.publishStaged(token, decoded)) { + scheduler.invalidate({ all: true }); + } + }) + .catch(() => { + // A transient decode failure leaves any prior preview untouched rather + // than blanking the canvas; the next selection re-drives a decode. + }); + }; + + /** + * Drops a layer's filter-preview state and bumps its token so an in-flight + * decode for it is discarded — even if the id is later reused (e.g. an undo + * that restores a deleted layer must not resurrect a stale decode result + * that resolves afterward). The token is bumped, never reset/deleted, so a + * later guarded preview for the same id can never collide with a + * still-in-flight decode's captured token. + */ + const clearFilterPreview = (layerId: string): void => { + if (renderController.previews.clearFilter(layerId)) { + scheduler.invalidate({ layers: [layerId] }); + } + }; + + /** + * Drops every layer's filter-preview state. Used on a wholesale document + * replace: none of the outgoing document's previews describe the incoming + * document, even if a layer id happens to be reused. + */ + const clearAllFilterPreviews = (): void => { + for (const id of renderController.previews.filterLayerIds()) { + clearFilterPreview(id); + } + }; + + const publishFilterPreview = async ( + layerId: string, + input: FilterPreviewInput, + validate: () => 'shown' | 'missing' | 'stale', + guard: LayerExportGuard + ): Promise<'shown' | 'missing' | 'stale'> => { + const nextToken = renderController.previews.beginGuardedFilter(layerId); + const dropGuardedRequest = (): void => { + renderController.previews.finishGuardedFilter(layerId, nextToken); + }; + const beforeDecode = validate(); + if (beforeDecode !== 'shown') { + dropGuardedRequest(); + return beforeDecode; + } + try { + const decoded = await decodeStagedPreview({ imageName: input.imageName }); + if (input.filterType && (decoded.width !== input.rect.width || decoded.height !== input.rect.height)) { + throw new LayerFilterOutputDimensionError( + input.filterType, + { height: decoded.height, width: decoded.width }, + input.rect + ); + } + const beforePublish = validate(); + if (beforePublish !== 'shown') { + dropGuardedRequest(); + return beforePublish; + } + // A newer set/clear for THIS layer superseded the decode in flight. + if (!renderController.previews.isFilterTokenCurrent(layerId, nextToken)) { + dropGuardedRequest(); + return 'stale'; + } + renderController.previews.publishFilter(layerId, nextToken, { + guard, + rect: { ...input.rect }, + surface: decoded.surface, + }); + scheduler.invalidate({ layers: [layerId] }); + return 'shown'; + } catch (error) { + // Transient decode failure leaves any prior preview untouched. + dropGuardedRequest(); + if (error instanceof LayerFilterOutputDimensionError) { + throw error; + } + return 'stale'; + } + }; + + const setGuardedFilterPreview = ( + layerId: string, + input: FilterPreviewInput, + guard: LayerExportGuard + ): Promise<'shown' | 'missing' | 'stale'> => { + const validate = (): 'shown' | 'missing' | 'stale' => { + const liveLayer = mirror.getDocument()?.layers.find((candidate) => candidate.id === layerId); + if (!liveLayer) { + return 'missing'; + } + if (layerId !== guard.layerId || !isLayerExportGuardCurrent(guard)) { + return 'stale'; + } + return 'shown'; + }; + return publishFilterPreview(layerId, input, validate, guard); + }; + + // ---- Render loop -------------------------------------------------------- + + /** + * The selected-layer bounds outline for the move overlay, or `null`. Only the + * move tool draws it; a layer mid-drag (with a live override) is preferred over + * the committed selection so the marquee tracks the preview. + */ + const moveOutlineCorners = (doc: CanvasDocumentContractV2): readonly { x: number; y: number }[] | null => { + if (interactionController.getActiveToolId() !== 'move') { + return null; + } + const overridden = doc.layers.find((layer) => transformOverrides.has(layer.id)); + const target = overridden ?? doc.layers.find((layer) => layer.id === doc.selectedLayerId); + if (!target) { + return null; + } + return layerOutlineCorners(target, doc, transformOverrides.get(target.id) ?? null); + }; + + /** The transform-tool frame (rotated bounds + handles + rotation nub), or `null`. */ + const transformFrameOverlay = ( + doc: CanvasDocumentContractV2 + ): { + corners: { x: number; y: number }[]; + handles: { x: number; y: number }[]; + center: { x: number; y: number }; + rotationAnchor: { x: number; y: number }; + } | null => { + if (interactionController.getActiveToolId() !== 'transform') { + return null; + } + const session = stores.transformSession.get(); + if (!session) { + return null; + } + const layer = doc.layers.find((candidate) => candidate.id === session.layerId); + // The layer's LOCAL content rect (off-origin aware): the frame must wrap the + // pixels where the compositor draws them, not an assumed origin-anchored box. + const rect = layer ? hittableLayerRect(layer, doc) : null; + if (!rect) { + return null; + } + return transformOverlayGeometry(session.transform, rect); + }; + + const render = (flags: RenderFlags): void => { + const screen = renderController.getScreen(); + const overlay = renderController.getOverlay(); + if (!screen || !overlay) { + return; + } + const doc = mirror.getDocument(); + const view = viewport.viewMatrix(viewport.getDpr()); + + if (!doc) { + clearSurface(screen); + clearSurface(overlay); + return; + } + + // The composited document only needs redrawing when pixels, layer order, or + // the viewport transform changed. An overlay-ONLY invalidation (the common + // hover case: a cursor-ring move dispatches `{ overlay: true }`) must NOT + // recomposite: the screen canvas retains its last frame, and the overlay is + // redrawn on top. Skipping the composite here is the single biggest zoom-lag + // win — a full composite up-scales every doc-sized layer surface to fill the + // screen, and that fill-rate grows with zoom, so recompositing on every hover + // move at high zoom is exactly the reported "laggier the closer you zoom in". + const needsComposite = flags.all || flags.view || flags.layers.size > 0; + const stagedPreview = renderController.previews.getStaged(); + const samPreview = renderController.previews.getSam(); + const filterPreviews = renderController.previews.filterSnapshot(); + if (needsComposite) { + const stagedPlacement = stagedPreview?.placement; + const isolatedGuard = samPreview?.isolated ? samPreview.guard : null; + const isolatedIds = isolatedGuard ? new Set([isolatedGuard.layerId]) : null; + const viewportSize = viewport.getViewportSize(); + const dpr = viewport.getDpr(); + const cssWidth = viewportSize.width > 0 ? viewportSize.width : screen.width / dpr; + const cssHeight = viewportSize.height > 0 ? viewportSize.height : screen.height / dpr; + const viewportTopLeft = viewport.screenToDocument({ x: 0, y: 0 }); + const viewportBottomRight = viewport.screenToDocument({ x: cssWidth, y: cssHeight }); + const liveCacheRects = new Map(); + for (const layer of doc.layers) { + const rect = layerCache.peek(layer.id)?.rect; + if (rect) { + liveCacheRects.set(layer.id, rect); + } + } + const activeFrameLayerIds = calculateActiveFrameLayerIds({ + document: doc, + isolationLayerIds: isolatedIds ?? undefined, + liveCacheRects, + transformOverrides: !isolatedGuard ? transformOverrides : undefined, + viewport: { + height: Math.abs(viewportBottomRight.y - viewportTopLeft.y), + width: Math.abs(viewportBottomRight.x - viewportTopLeft.x), + x: Math.min(viewportTopLeft.x, viewportBottomRight.x), + y: Math.min(viewportTopLeft.y, viewportBottomRight.y), + }, + }); + ensureLayerCaches(doc, activeFrameLayerIds); + const compositeDoc = isolatedIds + ? { ...doc, layers: doc.layers.filter((layer) => isolatedIds.has(layer.id)) } + : doc; + compositeDocument(screen, compositeDoc, layerCache, view, { + // Memoized adjusted surfaces for raster layers with brightness/contrast/ + // saturation/curves (not recomputed per frame — see adjustedSurfaceCache). + adjustedSurface: getAdjustedSurface, + derivedSurfaces: derivedSurfaceCache, + diagnostics, + // The raster backend + mask fill tile resolver drive the mask colorize + // path (alpha stencil → source-in fill colour/pattern, above all layers). + backend, + maskPatternTile: getMaskPatternTile, + // Non-destructive control-filter previews (drawn in place of the layer's + // committed pixels). Only allocated when a preview is actually active. + layerPreviews: + !isolatedGuard && filterPreviews.size > 0 + ? new Map(Array.from(filterPreviews, ([id, preview]) => [id, preview])) + : null, + clipRect: isolatedGuard && samPreview ? samPreview.rect : null, + // Feed the cached checkerboard tile only while the toggle is ON; passing + // `null` renders transparent documents without a checkerboard. + checkerboardTile: stores.checkerboard.get() ? getCheckerboardTile() : null, + // Crisp + cheap when zoomed in (nearest-neighbor up-scale), smooth when + // shrinking (bilinear down-scale). See `shouldSmoothAtZoom`. + imageSmoothing: shouldSmoothAtZoom(viewport.getZoom()), + // Candidate-specific placement wins for final images. Progress frames + // and legacy image inputs continue to follow the CURRENT bbox origin. + stagedPreview: + !isolatedGuard && stagedPreview + ? { + opacity: stagedPlacement?.opacity ?? 1, + rect: stagedPlacement + ? { + height: stagedPlacement.height, + width: stagedPlacement.width, + x: stagedPlacement.x, + y: stagedPlacement.y, + } + : { height: stagedPreview.height, width: stagedPreview.width, x: doc.bbox.x, y: doc.bbox.y }, + surface: stagedPreview.surface, + } + : null, + // While a text-edit session is open on a layer, skip it in the composite — + // the contenteditable portal shows its live text instead (avoids double-draw). + skipLayerId: stores.textEditSession.get()?.layerId ?? null, + transformOverrides: !isolatedGuard && transformOverrides.size > 0 ? transformOverrides : null, + }); + + rasterController.memory.setBaseBytes(layerCache.byteSize()); + rasterController.memory.setDerivedBytes(derivedSurfaceCache.byteSize()); + const protectedIds = new Set([...activeFrameLayerIds, ...rasterController.memory.pinnedLayerIds()]); + const memory = rasterController.memory.snapshot(); + const surfaceBudgetBytes = Math.max( + 0, + rasterController.memory.budgetBytes - memory.decodedBytes - memory.detachedBytes - memory.reservedBytes + ); + const { evictedBaseLayerIds: evicted } = enforceSurfaceBudget( + layerCache, + derivedSurfaceCache, + protectedIds, + surfaceBudgetBytes, + diagnostics + ); + rasterController.memory.setBaseBytes(layerCache.byteSize()); + rasterController.memory.setDerivedBytes(derivedSurfaceCache.byteSize()); + // Prune version-keyed dependents for every evicted id (mirrors dropLayer): + // the evicted layer's surface is gone, so its adjusted-surface memo and + // thumbnail state must not linger keyed to a version the re-rasterized entry + // will exceed (the cache floor keeps versions monotonic across the recreate). + for (const id of evicted) { + deleteDerivedSurfaces(id); + stores.thumbnailVersion.delete(id); + stores.thumbnailStatus.delete(id); + } + } + + // The overlay is cheap (a handful of screen-space strokes, independent of + // zoom and document size) and shares the `view` transform with the composite, + // so redraw it whenever any frame runs — including overlay-only frames. + // While the bbox tool drags, the transient preview stands in for the + // committed frame so the overlay (rect + handles) tracks the gesture. + const bboxPreview = stores.bboxPreview.get(); + const samSession = stores.samInteraction.get(); + renderOverlay(overlay, { + bbox: bboxPreview ?? doc.bbox, + bboxHandles: interactionController.getActiveToolId() === 'bbox', + cursor: overlayCursor, + // The grid spans the whole viewport at the bbox snap size when the setting + // is on (the document rect no longer bounds it). + gridSize: stores.bboxGrid.get(), + layerOutline: moveOutlineCorners(doc), + // The in-progress lasso path (transient) and the committed selection's + // animated marching ants. Both are overlay chrome; ants advance via the + // ants animator's overlay-only ticks. + gradientPreview: stores.gradientPreview.get(), + lassoPreview: stores.lassoPreview.get(), + marchingAnts: selection.hasSelection() ? { paths: selection.antsPaths(), phase: antsPhase } : null, + samInput: samSession?.input.type === 'visual' ? samSession.input : null, + samPreview: samPreview ? { opacity: 0.45, rect: samPreview.rect, surface: samPreview.data } : null, + bboxOverlay: stores.bboxOverlay.get(), + ruleOfThirds: stores.ruleOfThirds.get(), + shapePreview: stores.shapePreview.get(), + // The passive bbox frame follows the setting, but always renders while the + // bbox tool is active so its handles have a frame to attach to (editable). + showBbox: stores.showBbox.get() || interactionController.getActiveToolId() === 'bbox', + showGrid: stores.showGrid.get(), + transformFrame: transformFrameOverlay(doc), + view, + }); + }; + + const renderController = new RenderController({ + applyCursor: (value) => applyCursorToInput(value), + clearPreview: () => clearStagedPreview(), + getInputHandlers: () => ({ ...pipeline, onWheel, reset: () => pipeline.reset() }), + isEngineDisposed: () => disposed, + onPageHide: () => onPageHide(), + onVisibilityChange: () => onVisibilityChange(), + onWindowBlur: () => onWindowBlur(), + render, + setViewportReady: (ready) => stores.viewportReady.set(ready), + updateAnimation: () => updateAntsAnimation(), + updateCursor: () => updateCursor(), + }); + const scheduler: RenderScheduler = renderController.scheduler; + // Stay paused until attached: invalidations accumulate but never request a + // (DOM) frame, keeping the engine node-safe before it has render targets. + scheduler.pause(); + + // ---- Document mirror ---------------------------------------------------- + + const mirror: DocumentMirror = createDocumentMirror(mutationPort, { + // The bbox rectangle/handles are overlay chrome, so a bbox move is normally + // overlay-only (no recomposite). The one exception: a legacy/progress staged + // preview is drawn in the COMPOSITE at the current bbox origin, so it must + // recomposite to follow the bbox. Explicitly placed candidates do not. + onBboxChanged: () => { + const staged = renderController.previews.getStaged(); + scheduler.invalidate(staged && !staged.placement ? { all: true } : { overlay: true }); + }, + onDocumentReplaced: () => { + const cleanup = createCleanupAccumulator(); + cleanup.run(() => editingController.invalidateDocument()); + cleanup.run(() => pipeline.cancelActiveGesture()); + cleanup.run(cancelOpenControlPixelEdit); + const previousImageNames = rasterController.mirroredImageNames(); + cleanup.run(() => rasterController.invalidateDocument()); + cleanup.run(() => stores.thumbnailStatus.clear()); + // A wholesale document swap — project switch, dims/background change, or a + // snapshot restore that changes dims — invalidates the pixel history: its + // entries reference layers/pixels that no longer describe the live document. + // + // Cancel any in-flight tool gesture FIRST: a swap mid-drag leaves stale tool + // state (a bbox `startBbox`, a move drag anchor) whose pointer-up would + // otherwise commit against the replaced document. Routing through the + // pipeline clears `gestureActive` and runs the tool's `onPointerCancel`, so + // the tool drops its own transient state. + // Defensive: a non-bbox active tool won't have cleared a lingering preview. + cleanup.run(() => stores.bboxPreview.set(null)); + cleanup.run(() => history.clear()); + cleanup.run(endNudgeBurst); + // A transform session (which outlives individual gestures) belongs to the + // outgoing document; tear it down alongside its preview override. + cleanup.run(() => stores.transformSession.set(null)); + cleanup.run(() => transformOverrides.clear()); + // A text-edit session likewise belongs to the outgoing document; drop it. + cleanup.run(() => stores.textEditSession.set(null)); + // A staged preview belongs to the outgoing document's bbox/candidates; a + // wholesale swap (project switch, snapshot restore) invalidates it. + cleanup.run(clearStagedPreview); + // Per-layer control-filter previews likewise belong to the outgoing + // document — a swap can reuse a layer id with different content, so + // pruning only "missing" ids isn't enough; drop them all. + cleanup.run(clearAllFilterPreviews); + // The selection is document-scoped interaction state: a swap drops it (and + // any in-progress lasso preview), stopping the ants loop via onChange. + cleanup.run(() => selection.clear()); + cleanup.run(() => stores.lassoPreview.set(null)); + const doc = mirror.getDocument(); + const present = new Set(doc ? doc.layers.map((layer) => layer.id) : []); + rasterController.clearMirroredImages(); + rasterController.clearThumbnailKeys(); + for (const layer of doc?.layers ?? []) { + rasterController.setThumbnailKey(layer.id, getLayerThumbnailDisplayKey(layer)); + const imageName = layerImageName(layer); + if (imageName) { + rasterController.setMirroredImage(layer.id, imageName); + } + } + const trackedIds = rasterController.trackedImageIds(); + for (const layerId of trackedIds) { + if (!present.has(layerId)) { + cleanup.run(() => dropLayer(layerId)); + } else { + cleanup.run(() => rasterController.untrackLayerImage(layerId)); + } + } + // A wholesale replacement can reuse a layer id with a DIFFERENT source, so + // a surviving cache entry may hold pixels from the outgoing document. + // Invalidate EVERY id in the incoming document — not just ids whose + // reference happened to change — to force a re-rasterize from the new + // source; a diff can't be trusted across a full swap. + for (const layerId of present) { + cleanup.run(() => invalidateLayerCache(layerId)); + } + for (const imageName of previousImageNames) { + cleanup.run(() => releaseBitmapIfUnreferenced(imageName)); + } + // Persistence bookkeeping (the self-echo `lastApplied` map and pending + // debounced flushes) described the OLD document. Drop it so a reused layer + // id can't have its next legit persistence dispatch suppressed as a stale + // self-echo. + cleanup.run(() => bitmapStore.reset()); + cleanup.run(() => scheduler.invalidate({ all: true })); + cleanup.throwIfFailed(); + }, + onLayerOrderChanged: () => { + scheduler.invalidate({ all: true }); + }, + onLayersChanged: (ids, sourceChangedIds) => { + const cleanup = createCleanupAccumulator(); + if (controlPixelController?.isOpenFor(ids)) { + cleanup.run(() => pipeline.cancelActiveGesture()); + cleanup.run(cancelOpenControlPixelEdit); + } + const doc = mirror.getDocument(); + for (const id of sourceChangedIds) { + cleanup.run(() => editingController.invalidateLayer(id)); + } + for (const id of ids) { + cleanup.run(() => editingController.invalidateLayer(id)); + } + const present = new Set(doc ? doc.layers.map((layer) => layer.id) : []); + const sourceChanged = new Set(sourceChangedIds); + const previousImageNames = new Map(ids.map((id) => [id, rasterController.getMirroredImage(id)])); + for (const id of ids) { + const layer = doc?.layers.find((candidate) => candidate.id === id); + const imageName = layer ? layerImageName(layer) : null; + if (imageName) { + rasterController.setMirroredImage(id, imageName); + } else { + rasterController.deleteMirroredImage(id); + } + } + // A transform session outlives individual gestures (and any tool switch, + // including a temp modifier-hold), so it can easily outlive its own layer + // being deleted out from under it — e.g. deleted via the layers panel + // while the pointer is elsewhere, or while temp-switched to view/colorPicker. + // Tear it down (session + preview override) the same way a document + // replace does, rather than leaving a ghost session/override pointing at + // a layer id that no longer exists. + const session = stores.transformSession.get(); + const textSession = stores.textEditSession.get(); + for (const id of ids) { + const layer = doc?.layers.find((candidate) => candidate.id === id); + if (!present.has(id)) { + rasterController.deleteThumbnailKey(id); + cleanup.run(() => dropLayer(id)); + const previousImageName = previousImageNames.get(id); + if (previousImageName) { + cleanup.run(() => releaseBitmapIfUnreferenced(previousImageName)); + } + // A control-filter preview (session + decoded surface) belongs to a + // specific layer; a layer removed out from under an in-flight or + // already-decoded preview (delete via the layers panel, or an undo + // that removes it) must have its preview dropped and its decode + // token bumped, or a late-resolving decode — or a later undo that + // restores this same id — would repopulate a stale preview. + cleanup.run(() => clearFilterPreview(id)); + if (session && session.layerId === id) { + cleanup.run(cancelTransform); + } + // An edit-mode text session whose layer was deleted out from under it + // (layers panel, or undo of the add) is torn down the same way. + if (textSession && textSession.layerId === id) { + cleanup.run(cancelTextEdit); + } + continue; + } + const preview = renderController.previews.getFilter(id); + if (preview && !isLayerExportGuardCurrent(preview.guard)) { + cleanup.run(() => clearFilterPreview(id)); + } + if (!sourceChanged.has(id)) { + if (layer) { + const displayKey = getLayerThumbnailDisplayKey(layer); + if (rasterController.getThumbnailKey(id) !== displayKey) { + rasterController.setThumbnailKey(id, displayKey); + const currentVersion = stores.thumbnailVersion.get(id); + // Cache versions are positive. Negative display tokens cannot + // collide with the next cache publication and suppress its redraw. + stores.thumbnailVersion.set( + id, + currentVersion !== undefined && currentVersion < 0 ? currentVersion - 1 : -1 + ); + } + } + // Prop/transform-only change (opacity, blend, lock, visibility, + // rename, nudge): the layer object was replaced but its SOURCE + // reference is unchanged, so the rasterized pixels are still valid. + // Invalidating here would be wasteful for an image layer and + // *destructive* for an unflushed paint layer — a `bitmap: null` paint + // source rasterizes to a CLEARED surface, wiping strokes that live + // only in the cache until their debounced upload lands. The compositor + // applies transform/opacity/blend at draw time, so the scheduled + // recomposite below is all a prop change needs. + continue; + } + // The layer's source genuinely changed (image swap, or a paint-bitmap + // swap from undo/import). Self-echo guard: skip when it's the exact + // paint-bitmap ref the bitmap store just applied — the cache already + // holds those pixels, so re-rasterizing would needlessly re-fetch and + // could flicker. Any other swap invalidates → re-rasterizes. + if (layer) { + rasterController.setThumbnailKey(id, getLayerThumbnailDisplayKey(layer)); + } + cleanup.run(() => rasterController.untrackLayerImage(id)); + const previousImageName = previousImageNames.get(id); + if (previousImageName) { + cleanup.run(() => releaseBitmapIfUnreferenced(previousImageName)); + } + const source = getLayerSourceById(id); + if (bitmapStore.isSelfEcho(id, source)) { + continue; + } + cleanup.run(() => invalidateLayerCache(id)); + } + cleanup.run(() => scheduler.invalidate({ layers: ids })); + cleanup.throwIfFailed(); + }, + onStagingChanged: () => scheduler.invalidate({ overlay: true }), + }); + + for (const layer of mirror.getDocument()?.layers ?? []) { + rasterController.setThumbnailKey(layer.id, getLayerThumbnailDisplayKey(layer)); + const imageName = layerImageName(layer); + if (imageName) { + rasterController.setMirroredImage(layer.id, imageName); + } + } + + // A guarded filter preview belongs to one continuous active-project epoch. + // Switching away invalidates published and in-flight work, so returning to + // this project cannot resurrect it. + let projectWasPresent = mutationPort.getCanvasState() !== null; + const unsubscribeProjectPreviewLifecycle = mutationPort.subscribe(() => { + const projectIsPresent = mutationPort.getCanvasState() !== null; + if (projectWasPresent && !projectIsPresent) { + const cleanup = createCleanupAccumulator(); + cleanup.run(() => editingController.invalidateProject()); + cleanup.run(() => pipeline.cancelActiveGesture()); + cleanup.run(cancelOpenControlPixelEdit); + cleanup.run(() => rasterController.invalidateDocument()); + cleanup.run(() => stores.thumbnailStatus.clear()); + const ids = new Set(renderController.previews.filterLayerIds()); + for (const layerId of ids) { + cleanup.run(() => clearFilterPreview(layerId)); + } + projectWasPresent = false; + cleanup.throwIfFailed(); + return; + } + projectWasPresent = projectIsPresent; + }); + + // ---- Viewport → stores/scheduler --------------------------------------- + + // Set while `resize` drives `setViewportSize` synchronously: the resize path + // composites in the same task (the anti-strobe fix), so the viewport + // subscription must NOT also schedule a `{ view: true }` frame — that pending + // flag would recomposite identical content on the next rAF (a second full + // composite per ResizeObserver event during a panel-drag resize). + let suppressViewportInvalidate = false; + + const unsubscribeViewport = viewport.subscribe(() => { + stores.zoom.set(viewport.getZoom()); + if (!suppressViewportInvalidate) { + scheduler.invalidate({ view: true }); + } + }); + + // ---- Tool-option / setting stores → overlay + recomposite --------------- + // + // A brush/eraser size change must resize the cursor ring even with no pointer + // event; toggling the checkerboard must recomposite the document. + const unsubscribeBrushOptions = stores.brushOptions.subscribe(refreshBrushCursorRadius); + const unsubscribeEraserOptions = stores.eraserOptions.subscribe(refreshBrushCursorRadius); + const unsubscribeCheckerboard = stores.checkerboard.subscribe(() => scheduler.invalidate({ all: true })); + // New checker colors (theme/color-mode switch): drop the cached tile so it + // rebuilds with the fed colors on the next composite, then force a recomposite. + const unsubscribeCheckerColors = stores.checkerColors.subscribe(() => { + checkerboardTile = null; + scheduler.invalidate({ all: true }); + }); + // The grid lives on the (cheap) overlay; toggling it or changing its snap size + // only needs an overlay redraw, never a recomposite. + const unsubscribeShowGrid = stores.showGrid.subscribe(() => scheduler.invalidate({ overlay: true })); + const unsubscribeBboxGrid = stores.bboxGrid.subscribe(() => { + if (stores.showGrid.get()) { + scheduler.invalidate({ overlay: true }); + } + }); + // The bbox frame, bbox overlay shade, and rule-of-thirds guides live on the + // (cheap) overlay; toggling any only needs an overlay redraw, never a + // recomposite. (`snapToGrid` is a pure interaction preference the bbox tool + // reads on gesture — no render effect.) + const unsubscribeShowBbox = stores.showBbox.subscribe(() => scheduler.invalidate({ overlay: true })); + const unsubscribeBboxOverlay = stores.bboxOverlay.subscribe(() => scheduler.invalidate({ overlay: true })); + const unsubscribeRuleOfThirds = stores.ruleOfThirds.subscribe(() => scheduler.invalidate({ overlay: true })); + + // ---- Pointer / wheel / key input --------------------------------------- + // + // Normalization, capture, coalescing, temp-tool holds, and gesture cancel live + // in the pointer pipeline; wheel routing (zoom vs brush-size step) lives in the + // wheel handler. The engine just supplies seams and wires the DOM listeners. + + /** Steps the active brush/eraser diameter by one notch (ctrl+wheel or the `[`/`]` hotkeys). */ + const stepActiveBrushSize = (direction: 1 | -1): void => { + if (interactionController.getActiveToolId() === 'brush') { + const opts = stores.brushOptions.get(); + stores.brushOptions.set({ ...opts, size: stepBrushSize(opts.size, direction) }); + } else if (interactionController.getActiveToolId() === 'eraser') { + const opts = stores.eraserOptions.get(); + stores.eraserOptions.set({ ...opts, size: stepBrushSize(opts.size, direction) }); + } + }; + + const interactionController = new InteractionController({ + beforeSwitch: (from, to, switchOptions) => { + for (const listener of toolChangeListeners) { + listener({ from, temporary: switchOptions?.temporary === true, to }); + } + }, + getTool: (toolId) => tools.get(toolId), + getToolContext: () => toolContext, + invalidateOverlay: () => scheduler.invalidate({ overlay: true }), + isLocked: () => interactionLocked, + publishActiveTool: (toolId) => stores.activeTool.set(toolId), + stepBrushSize: stepActiveBrushSize, + updateCursor, + }); + const setTool = (toolId: ToolId, options?: { temporary?: boolean }): void => + interactionController.setTool(toolId, options); + + /** + * The engine's Escape priority ladder, run by the pointer pipeline AFTER it + * cancels any in-flight gesture, matching the planned chain "gesture → text + * session → transform → deselect": cancel an open text-edit session, else an + * open transform session, else deselect. A focused text portal consumes Escape + * itself (stopPropagation), so this window-level handler only reaches a + * defocused-but-open text session. Deselect is suppressed when a drag just + * consumed the Escape (`gestureWasActive`), so a mid-lasso Escape drops only the + * in-progress path, never the committed selection. Exposed for the pipeline + * wiring and node tests (the real DOM keydown listener can't run in node-env). + */ + const handleEscapePriority = ({ gestureWasActive }: { gestureWasActive: boolean }): void => { + if (stores.textEditSession.get()) { + cancelTextEdit(); + return; + } + if (stores.transformSession.get()) { + cancelTransform(); + return; + } + if (applicationEscapeHandler?.(gestureWasActive)) { + return; + } + if (!gestureWasActive && selection.hasSelection()) { + selection.clear(); + } + }; + + const pipeline: PointerPipeline = createPointerPipeline({ + getActiveTool: activeTool, + getActiveToolId: () => interactionController.getActiveToolId(), + getInputElement: () => renderController.getInputElement(), + getToolContext: () => toolContext, + handleEscape: handleEscapePriority, + hasTool: (id) => tools.has(id), + // A primary-button pointerdown while a text-edit session is open commits it + // (engine reads the live portal content). The pipeline swallows that press. + maybeCommitModalSession: () => commitOpenTextSession(), + setTool: (id, opts) => setTool(id, opts), + updateCursor, + viewport, + }); + + const onWheel = createWheelHandler({ + getActiveTool: activeTool, + getInputElement: () => renderController.getInputElement(), + getInvertBrushSizeScroll: () => stores.invertBrushSizeScroll.get(), + getToolContext: () => toolContext, + invalidate: (payload) => scheduler.invalidate(payload), + stepActiveBrushSize, + viewport, + }); + + // ---- Lifecycle: shrink the paint-loss window --------------------------- + // + // Unload cannot be reliably blocked, so these are fire-and-forget kicks that + // narrow the gap between the last paint and its upload; the real barrier is + // `flushPendingUploads()`, which invoke/export await. `blur` additionally + // resets the pointer pipeline so a held space/alt temp tool doesn't strand + // when the window loses focus mid-hold. + const kickPendingFlush = (): void => { + void persistenceController.flush(); + }; + const onPageHide = (): void => { + kickPendingFlush(); + }; + const onVisibilityChange = (): void => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + kickPendingFlush(); + } + }; + const onWindowBlur = (): void => { + pipeline.reset(); + }; + + const clearSamPreview = (): void => { + const previous = renderController.previews.clearSam(); + if (previous) { + scheduler.invalidate(previous.isolated ? { all: true } : { overlay: true }); + } + }; + + const decodeSelectObjectPreview = async ( + result: { image: CanvasImageRef; rect: Rect }, + signal: AbortSignal + ): Promise => { + const validateDecoded = (width: number, height: number): void => { + const valid = + Number.isInteger(width) && + width > 0 && + Number.isInteger(height) && + height > 0 && + width === result.image.width && + height === result.image.height && + width === result.rect.width && + height === result.rect.height; + if (!valid) { + throw Object.assign( + new Error( + `Decoded Select Object preview dimensions ${String(width)}x${String(height)} do not match SAM output ${result.image.width}x${result.image.height} and preview rect ${result.rect.width}x${result.rect.height}.` + ), + { samErrorCode: 'output-dimension' as const } + ); + } + }; + const decoded = await rasterController.decodeImage(result.image, { + scaleToImage: false, + signal, + validateDecoded, + }); + if (decoded.status !== 'ok') { + throw new DOMException('Select Object preview decode was aborted.', 'AbortError'); + } + const surface = decoded.surface; + surface.ctx.globalCompositeOperation = 'source-in'; + surface.ctx.fillStyle = '#38bdf8'; + surface.ctx.fillRect(0, 0, surface.width, surface.height); + surface.ctx.globalCompositeOperation = 'source-over'; + if (signal.aborted) { + throw new DOMException('Select Object preview decode was aborted.', 'AbortError'); + } + return surface; + }; + + // ---- Public API --------------------------------------------------------- + + const setInteractionLocked = (locked: boolean): void => { + if (interactionLocked === locked) { + return; + } + interactionLocked = locked; + if (locked) { + pipeline.cancelActiveGesture(); + setTool('view', { temporary: true }); + } + }; + + const attach = (screenCanvas: HTMLCanvasElement, overlayCanvas: HTMLCanvasElement): void => + renderController.attach(screenCanvas, overlayCanvas); + const detach = (): void => renderController.detach(); + + const activate = (): void => { + if (disposed) { + return; + } + rasterController.memory.releaseGeneration(lifecycleGeneration); + lifecycleGeneration += 1; + lifecycleState = 'active'; + editingController.activate(); + cooldownPromise = null; + }; + + const beginCooldown = (): Promise<'cooled' | 'dirty'> => { + if (disposed) { + return Promise.resolve('cooled'); + } + if (lifecycleState === 'cooling' && cooldownPromise) { + return cooldownPromise; + } + if (lifecycleState === 'cool') { + return Promise.resolve('cooled'); + } + psdExportController.cancel(); + rasterController.memory.releaseGeneration(lifecycleGeneration); + lifecycleGeneration += 1; + const generation = lifecycleGeneration; + lifecycleState = 'cooling'; + editingController.cooldown(); + detach(); + cancelAllLayerRasterizations(); + cooldownPromise = persistenceController.flush().then( + () => { + if (disposed || lifecycleState !== 'cooling' || lifecycleGeneration !== generation) { + return 'cooled'; + } + layerCache.dispose(); + derivedSurfaceCache.dispose(); + renderController.previews.clearFilters(); + clearStagedPreview(); + checkerboardTile = null; + maskPatternTiles.clear(); + stores.thumbnailStatus.clear(); + historyController.cooldown(); + lifecycleState = 'cool'; + return 'cooled'; + }, + () => { + if (!disposed && lifecycleGeneration === generation) { + // Retain the cooling state and live caches, but clear the completed + // attempt so a zero-reference registry entry can retry persistence. + cooldownPromise = null; + } + return 'dirty'; + } + ); + return cooldownPromise; + }; + + const resize = (cssWidth: number, cssHeight: number, dpr: number): void => { + // Suppress the viewport subscription's `{ view: true }` invalidate: the + // synchronous `render` below already repaints this size change, so letting the + // subscription schedule a frame would composite the identical result again on + // the next rAF (two full composites per resize event). + suppressViewportInvalidate = true; + viewport.setViewportSize(cssWidth, cssHeight, dpr); + suppressViewportInvalidate = false; + const backingDpr = Math.min(dpr, MAX_DPR); + const backingWidth = Math.round(cssWidth * backingDpr); + const backingHeight = Math.round(cssHeight * backingDpr); + renderController.resize(backingWidth, backingHeight); + // Composite SYNCHRONOUSLY, in this same task, right after the backing-store + // resize. Sizing a `` backing store clears it, so deferring the + // recomposite to the next rAF (the normal dirty-path) leaves a blank frame + // on screen until then — during a continuous panel-drag resize that reads as + // a flash/strobe. A same-task repaint lands before the browser paints, so the + // canvas never shows empty. `all: true` forces the composite through the T22 + // dirty gate; `render` no-ops when detached (no surfaces). + render({ all: true, layers: new Set(), overlay: true, view: true }); + }; + + const fitToView = (): void => { + const doc = mirror.getDocument(); + if (!doc) { + return; + } + // The document rect is no longer a spatial boundary — fit content ∪ bbox. The + // bbox (generation frame) is the primary anchor, so an empty canvas fits it; + // any renderable layer beyond the bbox is unioned in so it lands in view. + let bounds: Rect = { ...doc.bbox }; + for (const layer of doc.layers) { + if (isRenderableLayer(layer)) { + bounds = union(bounds, getSourceBounds(layer, doc)); + } + } + viewport.fitToView(bounds, viewport.getViewportSize()); + }; + + const isLayerCacheReadyForOp = (layer: CanvasLayerContract, doc: CanvasDocumentContractV2): boolean => { + if (isEmpty(getSourceContentRect(layer, doc))) { + return true; + } + const entry = layerCache.get(layer.id); + return !!entry && !entry.stale && !isCurrentRasterizationJob(layer); + }; + + const prepareGeneratedPaintCache = (layerId: string, rect: Rect, pixels: RasterSurface) => + layerCache.prepareReplacement(layerId, rect, pixels); + + const installGeneratedPaintCache = ( + prepared: ReturnType, + persist = true + ): void => { + const { layerId } = prepared; + const target = layerCache.installReplacement(prepared); + + // Allocation and raster drawing happen in prepareGeneratedPaintCache(). + // Once a document mutation has been dispatched and this detached cache has + // been installed, observer/scheduling/persistence hooks are notifications: + // none may veto the already-applied document+cache transaction. In normal + // production code these hooks do not throw; containment protects the + // transaction from a faulty subscriber or host scheduling implementation. + const notifyBestEffort = (notify: () => void): void => { + try { + notify(); + } catch { + // The document and cache are already converged. A later render or dirty + // mark can retry ancillary work without reporting a false failed commit. + } + }; + notifyBestEffort(() => deleteDerivedSurfaces(layerId)); + notifyBestEffort(() => stores.thumbnailVersion.set(layerId, target.version)); + if (renderController.previews.hasFilter(layerId)) { + notifyBestEffort(() => clearFilterPreview(layerId)); + } + notifyBestEffort(() => scheduler.invalidate({ layers: [layerId] })); + if (persist) { + notifyBestEffort(() => bitmapStore.markLayerDirty(layerId)); + } + }; + + const getReducerDocument = (): CanvasDocumentContractV2 | null => mutationPort.getCanvasState()?.document ?? null; + const getMainModelBase = (): string | null => { + return opts.getMainModelBase?.() ?? null; + }; + + const dispatchPreparedMutation = ( + action: CanvasProjectMutation, + isApplied: () => boolean, + isMirrored: () => boolean + ): void => { + try { + if (!mutationPort.dispatch(action)) { + throw new Error('Canvas document mutation was rejected'); + } + } catch (error) { + // Store subscribers run after the reducer has accepted an action. A + // faulty observer must not strand an applied document mutation before + // its matching engine state and history are published. Preserve real + // reducer/dispatch failures by swallowing only when the exact intended + // postcondition is visible in the authoritative reducer state. + if (!isApplied()) { + throw error; + } + // Notification may have been interrupted before DocumentMirror's + // subscriber ran. Reconcile it synchronously from authoritative state + // before publishing follow-up state or history. + try { + mirror.refresh(); + } catch (refreshError) { + if (!isMirrored()) { + throw refreshError; + } + } + if (!isMirrored()) { + throw error; + } + return; + } + + // A reducer may reject a guarded transaction by returning the unchanged + // state without throwing. Do not install its prepared cache or consume a + // failure-atomic history entry unless the authoritative postcondition + // actually landed. + if (!isApplied()) { + throw new Error('Canvas document mutation was rejected'); + } + if (!isMirrored()) { + try { + mirror.refresh(); + } catch (refreshError) { + if (!isMirrored()) { + throw refreshError; + } + } + if (!isMirrored()) { + throw new Error('Canvas document mutation was not mirrored'); + } + } + }; + + /** Conversion reducers clone contracts, so their publication postcondition compares by value. */ + const documentHasLayerContract = ( + document: CanvasDocumentContractV2 | null, + expected: CanvasLayerContract + ): boolean => { + const current = document?.layers.find((candidate) => candidate.id === expected.id); + return current !== undefined && isDeeplyEqual(current, expected); + }; + + controlPixelController = new ControlPixelController({ + applyImagePatch, + backend, + bitmapStore, + canEdit: () => canEditDocument(), + deleteDerived: deleteDerivedSurfaces, + dispatchReplacement: (layer) => + dispatchPreparedMutation( + { layer, layerId: layer.id, type: 'replaceCanvasLayer' }, + () => documentHasLayerContract(getReducerDocument(), layer), + () => documentHasLayerContract(mirror.getDocument(), layer) + ), + endBurst: () => endNudgeBurst(), + getActiveProjectId: () => projectId, + getDocument: () => mirror.getDocument(), + getTransformSession: () => stores.transformSession.get(), + history, + installPrepared: installGeneratedPaintCache, + invalidate: (layerId, overlay) => scheduler.invalidate({ layers: [layerId], overlay: overlay || undefined }), + isCacheReady: isLayerCacheReadyForOp, + isOperationIdle: () => !stores.documentEditingLocked.get(), + layers: layerCache, + notifyPainted: notifyLayerPainted, + preparePixels: prepareGeneratedPaintCache, + projectId, + publishStroke: (event) => { + for (const listener of strokeListeners) { + listener(event); + } + }, + setTransformOverride: (layerId, transform) => { + if (transform) { + transformOverrides.set(layerId, transform); + } else { + transformOverrides.delete(layerId); + } + }, + }); + const beginControlPixelEdit = controlPixelController.begin.bind(controlPixelController); + + const captureLayerCache = ( + layer: CanvasLayerContract, + doc: CanvasDocumentContractV2 + ): { pixels: RasterSurface; rect: Rect } | null | 'not-ready' => { + const entry = layerCache.get(layer.id); + if (!entry || isEmpty(entry.rect)) { + return null; + } + if (isCurrentRasterizationJob(layer) || (entry.stale && !isEmpty(getSourceContentRect(layer, doc)))) { + return 'not-ready'; + } + const pixels = backend.createSurface(entry.rect.width, entry.rect.height); + pixels.ctx.drawImage(entry.surface.canvas, 0, 0); + return { pixels, rect: { ...entry.rect } }; + }; + + const layerNeedsPixelPersistence = (layer: CanvasLayerContract): boolean => + renderableSourceOf(layer)?.type === 'paint'; + + const layerMutationController = new LayerMutationController({ + canEdit: () => canEditDocument(), + captureCache: captureLayerCache, + discardPersisted: (layerId) => bitmapStore.discardLayer(layerId), + dispatchPrepared: dispatchPreparedMutation, + endBurst: () => endNudgeBurst(), + getDocument: () => mirror.getDocument(), + getReducerDocument, + history, + installPrepared: installGeneratedPaintCache, + isGestureActive: () => pipeline.isGestureActive(), + needsPixelPersistence: layerNeedsPixelPersistence, + preparePixels: prepareGeneratedPaintCache, + sameContract: documentHasLayerContract, + }); + const commitLayerCopy = layerMutationController.copy.bind(layerMutationController); + const commitLayerConversion = layerMutationController.convert.bind(layerMutationController); + + const replaceSelectionFromImage = editingController.selectionImage.replace.bind(editingController.selectionImage); + + const maskResultController = new MaskResultController({ + canEdit: (owner) => canEditDocument(owner), + createLayerId, + dispatchPrepared: dispatchPreparedMutation, + endBurst: () => endNudgeBurst(), + getDocument: () => mirror.getDocument(), + getReducerDocument, + history, + isGestureActive: () => pipeline.isGestureActive(), + isGuardCurrent: isLayerExportGuardCurrent, + }); + const commitMaskImageResult = maskResultController.commit.bind(maskResultController); + + const filterResultController = new FilterResultController({ + captureCache: captureLayerCache, + capturePermit: (owner) => captureDocumentEditPermit(owner), + createLayerId, + decodeImage: (image, options) => rasterController.decodeImage(image, options), + discardPersisted: (layerId) => bitmapStore.discardLayer(layerId), + dispatchPrepared: dispatchPreparedMutation, + endBurst: () => endNudgeBurst(), + getDocument: () => mirror.getDocument(), + getMainModelBase, + getReducerDocument, + history, + installPrepared: installGeneratedPaintCache, + isGestureActive: () => pipeline.isGestureActive(), + isGuardCurrent: isLayerExportGuardCurrent, + isPermitCurrent: (permit) => isDocumentEditPermitCurrent(permit as DocumentEditPermit), + needsPixelPersistence: layerNeedsPixelPersistence, + preparePixels: prepareGeneratedPaintCache, + }); + const commitRasterFilterResult = filterResultController.commit.bind(filterResultController); + + const generatedResultController = new GeneratedResultController({ + captureCache: captureLayerCache, + capturePermit: (owner) => captureDocumentEditPermit(owner), + clearPreview: clearFilterPreview, + createLayerId, + decodeImage: (image, options) => rasterController.decodeImage(image, options), + discardPersisted: (layerId) => bitmapStore.discardLayer(layerId), + dispatchPrepared: dispatchPreparedMutation, + endBurst: () => endNudgeBurst(), + getDocument: () => mirror.getDocument(), + getMainModelBase, + getReducerDocument, + history, + installPrepared: installGeneratedPaintCache, + isGestureActive: () => pipeline.isGestureActive(), + isGuardCurrent: isLayerExportGuardCurrent, + isPermitCurrent: (permit) => isDocumentEditPermitCurrent(permit as DocumentEditPermit), + needsPixelPersistence: layerNeedsPixelPersistence, + preparePixels: prepareGeneratedPaintCache, + }); + const commitGeneratedImageResult = generatedResultController.commit.bind(generatedResultController); + + const stagedResultController = new StagedResultController({ + capturePermit: (owner) => captureDocumentEditPermit(owner), + createEventId, + createLayerId, + dispatchPrepared: dispatchPreparedMutation, + endBurst: () => endNudgeBurst(), + getCanvasState: () => mutationPort.getCanvasState(), + getDocument: () => mirror.getDocument(), + history, + isGestureActive: () => pipeline.isGestureActive(), + isPermitCurrent: (permit) => isDocumentEditPermitCurrent(permit as DocumentEditPermit), + now: () => new Date().toISOString(), + }); + const commitStagedImage = stagedResultController.commit.bind(stagedResultController); + + const booleanMergeRasterLayers = ( + upperLayerId: string, + operation: BooleanRasterOperation + ): Promise => layerController.booleanMerge.merge(upperLayerId, operation); + + const extractMaskedArea = (maskLayerId: string): Promise => + layerController.extractMaskedArea.extract(maskLayerId); + + const mergeLayerDown = (upperLayerId: string): boolean => layerController.merge.mergeDown(upperLayerId); + const mergeVisibleRasterLayers = (): Promise => layerController.merge.mergeVisible(); + + let captureDocumentSnapshot!: () => CanvasDocumentSnapshot | null; + let isDocumentSnapshotCurrent!: (snapshot: CanvasDocumentSnapshot) => boolean; + let captureRasterSnapshot!: ( + documentSnapshot: CanvasDocumentSnapshot, + layerIds: readonly string[], + options?: { signal?: AbortSignal; includeDisabled?: boolean } + ) => Promise; + const psdExportController = new PsdExportController({ + backend, + captureDocumentSnapshot: () => captureDocumentSnapshot(), + captureRasterSnapshot: (snapshot, layerIds, options) => captureRasterSnapshot(snapshot, layerIds, options), + getAvailableBytes: () => { + rasterController.memory.setBaseBytes(layerCache.byteSize()); + rasterController.memory.setDerivedBytes(derivedSurfaceCache.byteSize()); + return rasterController.memory.getAvailableBytes(); + }, + isDocumentSnapshotCurrent: (snapshot) => isDocumentSnapshotCurrent(snapshot), + reserve: (bytes) => { + rasterController.memory.setBaseBytes(layerCache.byteSize()); + rasterController.memory.setDerivedBytes(derivedSurfaceCache.byteSize()); + return rasterController.memory.reserve(bytes, { generation: lifecycleGeneration, purpose: 'psd-export' }); + }, + }); + const exportRasterLayersToPsd = (fileName: string): Promise => psdExportController.export(fileName); + + const rasterizeLayer = (layerId: string): boolean => layerController.rasterize.rasterize(layerId); + + // ---- Transform session -------------------------------------------------- + // + // The transform tool opens a session on one layer (start/live transform in + // `stores.transformSession`, preview via `transformOverrides`) that outlives + // individual pointer gestures. Apply commits — a param edit for image layers, + // a pixel bake for paint layers — as ONE undoable entry; Cancel drops the + // preview. The transform tool drives begin/update/cancel through the tool + // context; React (numeric bar + Apply/Cancel buttons) drives the public API. + + const beginTransformSession = (layerId: string): void => editingController.transform.begin(layerId); + const updateTransformSession = (transform: LayerTransform): void => editingController.transform.update(transform); + const cancelTransform = (): void => editingController.transform.cancel(); + const applyTransform = (): void => editingController.transform.apply(); + + // ---- Text editing session ----------------------------------------------- + // + // The text tool opens a session (create or edit) exposed through + // `stores.textEditSession`; React renders a contenteditable portal over it and + // drives the commit (blur / mod+enter) — the engine never sees per-keystroke + // content, so commit takes the final content from React. ONE commit per close: + // create → `addCanvasLayer` (inverse removes), edit → `updateCanvasLayerSource` + // (exact inverse). A no-change / empty-create commit dispatches nothing (cancel + // semantics). The options bar restyles the live session via `updateTextEditStyle`. + + const setTextEditContentReader = (reader: (() => string) | null): void => + editingController.text.setContentReader(reader); + const openTextCreate = (point: Vec2): void => editingController.text.openCreate(point); + const openTextEdit = (layerId: string): void => editingController.text.openEdit(layerId); + const updateTextEditStyle = (patch: Partial): void => editingController.text.updateStyle(patch); + const cancelTextEdit = (): void => editingController.text.cancel(); + const commitTextEdit = (content: string, styleChanges?: Partial): void => + editingController.text.commit(content, styleChanges); + const commitOpenTextSession = (): boolean => editingController.text.commitOpen(); + + // ---- Selection public API ----------------------------------------------- + + /** + * The bounded domain selectAll/invert operate over now that the document rect is + * retired: `content ∪ bbox` — the same union `fitToView` fits. The bbox anchors + * an empty canvas; any renderable layer beyond it is unioned in. The closest + * coherent analogue of legacy's bounded canvas for the complement in `invert`. + */ + const selectAll = (): void => editingController.selectAll(); + const deselect = (): void => editingController.deselect(); + const invertSelection = (): void => editingController.invertSelection(); + + const fillSelection = (): void => editingController.selectionPixels.run('fill'); + const eraseSelection = (): void => editingController.selectionPixels.run('erase'); + + const clearMask = (layerId: string): boolean => layerController.mask.clear(layerId); + const dispose = (): void => { + if (disposed) { + return; + } + disposed = true; + for (const snapshot of activeRasterSnapshots) { + snapshot.release(); + } + rasterController.memory.releaseGeneration(lifecycleGeneration); + lifecycleGeneration += 1; + lifecycleState = 'disposed'; + const cleanup = createCleanupAccumulator(); + cleanup.run(() => pipeline.cancelActiveGesture()); + cleanup.run(cancelOpenControlPixelEdit); + cleanup.run(() => controlPixelController?.dispose()); + cleanup.run(() => filterResultController.dispose()); + cleanup.run(() => generatedResultController.dispose()); + cleanup.run(() => stagedResultController.dispose()); + cleanup.run(() => editingController.dispose()); + cleanup.run(() => layerController.dispose()); + cleanup.run(() => layerMutationController.dispose()); + cleanup.run(() => maskResultController.dispose()); + cleanup.run(() => interactionController.dispose()); + cleanup.run(() => psdExportController.dispose()); + cleanup.run(() => rasterExportController.dispose()); + cleanup.run(cancelAllLayerRasterizations); + cleanup.run(detach); + // Drop any open text-edit session (its layer belongs to a document this + // engine no longer serves). + cleanup.run(() => stores.textEditSession.set(null)); + // Drop any guarded filter previews outright — the engine is going away, so + // there's no render loop left to invalidate for them. + cleanup.run(() => antsAnimator.stop()); + cleanup.run(() => activeTool()?.onDeactivate?.(toolContext)); + cleanup.run(unsubscribeViewport); + cleanup.run(unsubscribeBrushOptions); + cleanup.run(unsubscribeEraserOptions); + cleanup.run(unsubscribeCheckerboard); + cleanup.run(unsubscribeCheckerColors); + cleanup.run(unsubscribeShowGrid); + cleanup.run(unsubscribeBboxGrid); + cleanup.run(unsubscribeShowBbox); + cleanup.run(unsubscribeBboxOverlay); + cleanup.run(unsubscribeRuleOfThirds); + cleanup.run(unsubscribeProjectPreviewLifecycle); + cleanup.run(unsubscribeDocumentEditingLock); + cleanup.run(() => historyController.dispose()); + cleanup.run(() => persistenceController.dispose()); + cleanup.run(() => mirror.dispose()); + cleanup.run(() => renderController.dispose()); + cleanup.run(() => rasterController.dispose()); + cleanup.run(() => stores.thumbnailStatus.clear()); + cleanup.run(() => strokeListeners.clear()); + cleanup.run(() => toolChangeListeners.clear()); + cleanup.run(() => { + samInputHandler = null; + }); + cleanup.throwIfFailed(); + }; + + const onStrokeCommitted = (listener: (event: StrokeCommittedEvent) => void): (() => void) => { + strokeListeners.add(listener); + return () => { + strokeListeners.delete(listener); + }; + }; + + const clearCaches = async (): Promise => { + // Flush pending paint-bitmap uploads FIRST: an unflushed stroke lives only in + // the live `layerCache` until the debounced (1500ms) flush persists it. If we + // invalidated the cache before flushing, that in-flight stroke would be + // destroyed — the next composite re-rasterizes from the (older) source. + await persistenceController.flush(); + const doc = mirror.getDocument(); + // Invalidate (mark stale → re-rasterize) every live layer cache and drop its + // memoized adjusted surface; the next composite rebuilds them from source. + for (const layer of doc?.layers ?? []) { + invalidateLayerCache(layer.id); + deleteDerivedSurfaces(layer.id); + } + // Drop the derived pattern tiles so they rebuild from the current fed colors. + checkerboardTile = null; + maskPatternTiles.clear(); + scheduler.invalidate({ all: true }); + }; + + const clearHistory = (): void => historyController.clear(); + + const logDebugInfo = (): void => { + const doc = mirror.getDocument(); + // eslint-disable-next-line no-console + console.info('[canvas-engine] debug info', { + activeTool: interactionController.getActiveToolId(), + bbox: doc?.bbox ?? null, + canRedo: history.canRedo(), + canUndo: history.canUndo(), + document: doc ? { height: doc.height, layers: doc.layers.length, width: doc.width } : null, + hasSelection: selection.hasSelection(), + projectId, + selectedLayerId: doc?.selectedLayerId ?? null, + zoom: viewport.getZoom(), + }); + }; + + const contextMenuLayerIdAt = (screenPoint: Vec2): string | null => { + // Never open the menu over an in-progress edit: a live paint/drag gesture, or + // an open transform / text-edit session. Right-click during those belongs to + // the interaction, not to picking a layer. Mirrors the mid-gesture guards on + // merge/nudge/undo above. + if (pipeline.isGestureActive() || stores.transformSession.get() || stores.textEditSession.get()) { + return null; + } + const doc = mirror.getDocument(); + if (!doc) { + return null; + } + // Same screen→document conversion and group-rank-consistent, live-cache-aware + // hit-test the move tool uses for click-selection, so right-clicking a layer + // targets exactly the layer a left-click there would select. + const documentPoint = viewport.screenToDocument(screenPoint); + const hit = topLayerAt( + doc, + documentPoint, + (layer) => layer.isEnabled, + (layerId) => layerCache.get(layerId)?.rect + ); + return hit?.id ?? null; + }; + + const undo = (): void => historyController.undo(); + const redo = (): void => historyController.redo(); + const setBboxGrid = (size: number): void => stores.bboxGrid.set(size > 0 ? size : 1); + const getViewport = (): Viewport => viewport; + const getCompositeExecutorDeps = (): CanvasCompositeExecutorDeps => ({ + backend, + getLayerSurface: requireLayerSurfaceForExport, + reserve: (bytes) => { + rasterController.memory.setBaseBytes(layerCache.byteSize()); + rasterController.memory.setDerivedBytes(derivedSurfaceCache.byteSize()); + return rasterController.memory.reserveOperation(bytes, { purpose: 'invocation-composite' }); + }, + uploadImage: (blob) => opts.uploadImage(blob), + }); + const exportRasterComposite = (request: RasterCompositeExportRequest) => + exportRasterCompositeWithDeps(request, { + backend, + captureSnapshot: (): RasterCompositeExportSnapshot => ({ + contentEpoch: rasterContentEpoch, + document: mirror.getDocument(), + documentGeneration: rasterController.getDocumentGeneration(), + lifecycleGeneration, + }), + getLayerSurface: requireLayerSurfaceForExport, + isSnapshotCurrent: (snapshot) => + !disposed && + mutationPort.getCanvasState() !== null && + snapshot.contentEpoch === rasterContentEpoch && + snapshot.document === mirror.getDocument() && + snapshot.documentGeneration === rasterController.getDocumentGeneration() && + snapshot.lifecycleGeneration === lifecycleGeneration, + pin: (layerIds) => { + const leases = layerIds.map((layerId) => rasterController.memory.pinOperation(layerId)); + let released = false; + return { + release: () => { + if (released) { + return; + } + released = true; + for (const lease of leases) { + lease.release(); + } + }, + }; + }, + reserve: (bytes) => { + rasterController.memory.setBaseBytes(layerCache.byteSize()); + rasterController.memory.setDerivedBytes(derivedSurfaceCache.byteSize()); + return rasterController.memory.reserveOperation(bytes, { purpose: 'background-snapshot' }); + }, + }); + const surface: CanvasSurfaceCapability = { attach, detach, resize }; + const viewportCapability: CanvasViewportCapability = { fitToView, getViewport, setBboxGrid }; + const historyCapability: CanvasHistoryCapability = { clearHistory, redo, undo }; + const lifecycle: CanvasLifecycleCapability = { + activate, + beginCooldown, + dispose, + flushPendingUploads: () => persistenceController.flush(), + getLifecycleState: () => lifecycleState, + }; + const layerController = new LayerController({ + booleanMerge: { + backend, + capturePermit: () => captureDocumentEditPermit(), + createLayerId, + dispatchPrepared: dispatchPreparedMutation, + endBurst: () => endNudgeBurst(), + exportBaked: (layerId) => exportBakedLayerPixelsForStructural(layerId), + getDocument: () => mirror.getDocument(), + getReducerDocument, + history, + installPrepared: (prepared) => + installGeneratedPaintCache(prepared as ReturnType), + isCacheReady: isLayerCacheReadyForOp, + isGestureActive: () => pipeline.isGestureActive(), + isGuardCurrent: isLayerExportGuardCurrent, + isPermitCurrent: (permit) => isDocumentEditPermitCurrent(permit as DocumentEditPermit), + preparePixels: prepareGeneratedPaintCache, + }, + crop: { + backend, + captureCache: captureLayerCache, + capturePermit: () => captureDocumentEditPermit(), + discardPersisted: (layerId) => bitmapStore.discardLayer(layerId), + dispatchPrepared: dispatchPreparedMutation, + endBurst: () => endNudgeBurst(), + exportBaked: (layerId) => exportBakedLayerPixelsForStructural(layerId, { includeDisabled: true }), + getDocument: () => mirror.getDocument(), + getReducerDocument, + history, + installPrepared: (prepared) => + installGeneratedPaintCache(prepared as ReturnType), + isGestureActive: () => pipeline.isGestureActive(), + isGuardCurrent: isLayerExportGuardCurrent, + isPermitCurrent: (permit) => isDocumentEditPermitCurrent(permit as DocumentEditPermit), + isSupportedSource: isSupportedExportSource, + preparePixels: prepareGeneratedPaintCache, + }, + copy: { + capturePermit: () => captureDocumentEditPermit(), + createLayerId, + dispatchPrepared: dispatchPreparedMutation, + endBurst: () => endNudgeBurst(), + exportBaked: (layerId) => exportBakedLayerPixelsForStructural(layerId, { includeDisabled: true }), + getDocument: () => mirror.getDocument(), + getReducerDocument, + history, + installPrepared: (prepared) => + installGeneratedPaintCache(prepared as ReturnType), + isGestureActive: () => pipeline.isGestureActive(), + isGuardCurrent: isLayerExportGuardCurrent, + isPermitCurrent: (permit) => isDocumentEditPermitCurrent(permit as DocumentEditPermit), + preparePixels: prepareGeneratedPaintCache, + }, + extractMaskedArea: { + backend, + capturePermit: () => captureDocumentEditPermit(), + createLayerId, + derived: derivedSurfaceCache, + diagnostics, + dispatchPrepared: dispatchPreparedMutation, + endBurst: () => endNudgeBurst(), + exportBaked: (layerId, includeDisabled) => exportBakedLayerPixelsForStructural(layerId, { includeDisabled }), + getAdjustedSurface, + getDocument: () => mirror.getDocument(), + getMaskPattern: getMaskPatternTile, + getReducerDocument, + hasExportableContent: hasExportableLayerContent, + history, + installPrepared: (prepared) => + installGeneratedPaintCache(prepared as ReturnType), + isCacheReady: isLayerCacheReadyForOp, + isGestureActive: () => pipeline.isGestureActive(), + isGuardCurrent: isLayerExportGuardCurrent, + isPermitCurrent: (permit) => isDocumentEditPermitCurrent(permit as DocumentEditPermit), + layers: layerCache, + preparePixels: prepareGeneratedPaintCache, + rasterize: (layerId) => rasterizeLayerPixelsForStructural(layerId), + }, + commitGeneratedImageResult, + mask: { + applyImagePatch, + canEdit: () => canEditDocument(), + deleteDerived: deleteDerivedSurfaces, + discardPersisted: (layerId) => bitmapStore.discardLayer(layerId), + dispatch: (action) => mutationPort.dispatch(action), + endBurst: () => endNudgeBurst(), + getDocument: () => mirror.getDocument(), + history, + isCacheReady: isLayerCacheReadyForOp, + isGestureActive: () => pipeline.isGestureActive(), + layers: layerCache, + markDirty: (layerId) => bitmapStore.markLayerDirty(layerId), + notifyPainted: notifyLayerPainted, + restoreCache: restoreLayerCache, + }, + merge: { + backend, + canEdit: () => canEditDocument(), + capturePermit: () => captureDocumentEditPermit(), + createLayerId, + dispatch: (action) => mutationPort.dispatch(action), + dispatchPrepared: dispatchPreparedMutation, + endBurst: () => endNudgeBurst(), + exportBaked: (layerId) => exportBakedLayerPixelsForStructural(layerId), + getDocument: () => mirror.getDocument(), + getReducerDocument, + hasExportableContent: hasExportableLayerContent, + history, + installPrepared: (prepared) => + installGeneratedPaintCache(prepared as ReturnType), + isCacheReady: isLayerCacheReadyForOp, + isGestureActive: () => pipeline.isGestureActive(), + isGuardCurrent: isLayerExportGuardCurrent, + isPermitCurrent: (permit) => isDocumentEditPermitCurrent(permit as DocumentEditPermit), + layers: layerCache, + markDirty: (layerId) => bitmapStore.markLayerDirty(layerId), + notifyPainted: notifyLayerPainted, + preparePixels: prepareGeneratedPaintCache, + }, + rasterize: { + backend, + canEdit: () => canEditDocument(), + dispatch: (action) => mutationPort.dispatch(action), + endBurst: () => endNudgeBurst(), + getDocument: () => mirror.getDocument(), + history, + isGestureActive: () => pipeline.isGestureActive(), + layers: layerCache, + markDirty: (layerId) => bitmapStore.markLayerDirty(layerId), + notifyPainted: notifyLayerPainted, + rasterizeDeps: (document) => rasterizeDeps(document), + }, + structural: structuralController, + thumbnail: { + backend, + getActiveProjectId: () => projectId, + getCheckerboard: getCheckerboardTile, + getDocument: () => mirror.getDocument(), + getEntry: (layerId) => layerCache.get(layerId), + getMaskPattern: getMaskPatternTile, + isDisposed: () => disposed, + isSupportedSource: isSupportedExportSource, + pin: (layerId) => rasterController.memory.pin(layerId, lifecycleGeneration), + projectId, + rasterize: rasterizeLayerForThumbnail, + reportError: (layerId, error) => { + try { + reportError('Layer thumbnail rasterization failed', layerId, error); + } catch { + // Diagnostics must not turn a handled thumbnail failure into a rejection. + } + }, + reserve: (bytes) => { + rasterController.memory.setBaseBytes(layerCache.byteSize()); + rasterController.memory.setDerivedBytes(derivedSurfaceCache.byteSize()); + return rasterController.memory.reserve(bytes, { generation: lifecycleGeneration, purpose: 'thumbnail' }); + }, + setStatus: (layerId, status) => { + if (status) { + stores.thumbnailStatus.set(layerId, status); + } else { + stores.thumbnailStatus.delete(layerId); + } + }, + }, + }); + const documentSnapshotSources = new WeakMap< + CanvasDocumentSnapshot, + { + canvas: NonNullable>; + contentEpoch: number; + lifecycleGeneration: number; + } + >(); + isDocumentSnapshotCurrent = (snapshot: CanvasDocumentSnapshot): boolean => { + const source = documentSnapshotSources.get(snapshot); + return ( + !disposed && + source !== undefined && + source.canvas === mutationPort.getCanvasState() && + source.contentEpoch === rasterContentEpoch && + source.lifecycleGeneration === lifecycleGeneration && + snapshot.documentGeneration === rasterController.getDocumentGeneration() + ); + }; + captureRasterSnapshot = async ( + documentSnapshot: CanvasDocumentSnapshot, + layerIds: readonly string[], + options?: { signal?: AbortSignal; includeDisabled?: boolean } + ): Promise => { + if (options?.signal?.aborted) { + return { status: 'aborted' }; + } + if (!documentSnapshotSources.has(documentSnapshot)) { + return { status: 'not-ready' }; + } + if (!isDocumentSnapshotCurrent(documentSnapshot)) { + return { status: 'stale' }; + } + const snapshotSource = documentSnapshotSources.get(documentSnapshot)!; + const captureLifecycleGeneration = snapshotSource.lifecycleGeneration; + + const uniqueLayerIds = [...new Set(layerIds)]; + const layerById = new Map(documentSnapshot.canvas.document.layers.map((layer) => [layer.id, layer])); + let requestedBytes = 0; + for (const layerId of uniqueLayerIds) { + const layer = layerById.get(layerId); + const source = layer ? renderableSourceOf(layer) : null; + if (!layer || !source || !isSupportedExportSource(source)) { + return { status: 'not-ready' }; + } + if (source.type === 'image') { + requestedBytes += source.image.width * source.image.height * 4; + } else if (source.type === 'paint' && source.bitmap) { + requestedBytes += source.bitmap.width * source.bitmap.height * 4; + } else { + const contentRect = getSourceContentRect(layer, documentSnapshot.canvas.document); + requestedBytes += contentRect.width * contentRect.height * 4; + } + } + + rasterController.memory.setBaseBytes(layerCache.byteSize()); + rasterController.memory.setDerivedBytes(derivedSurfaceCache.byteSize()); + const reservation = rasterController.memory.reserve(requestedBytes, { + generation: captureLifecycleGeneration, + purpose: 'background-snapshot', + }); + if (reservation.status === 'over-budget') { + return { status: 'over-budget' }; + } + const reservationLeases: { release(): void }[] = [reservation.lease]; + const pinLeases = uniqueLayerIds.map((layerId) => rasterController.memory.pin(layerId, captureLifecycleGeneration)); + const layerSurfaces = new Map(); + const emptyLayerIds = new Set(); + const capturedGuards: LayerExportGuard[] = []; + let actualDetachedBytes = 0; + try { + for (const layerId of uniqueLayerIds) { + if (options?.signal?.aborted) { + return { status: 'aborted' }; + } + if (!isDocumentSnapshotCurrent(documentSnapshot)) { + return { status: 'stale' }; + } + const liveResult = await rasterizeLayerPixels(layerId, { + includeDisabled: options?.includeDisabled, + signal: options?.signal, + }); + if (options?.signal?.aborted) { + return { status: 'aborted' }; + } + if (!isDocumentSnapshotCurrent(documentSnapshot)) { + return { status: 'stale' }; + } + if (liveResult.status === 'empty') { + emptyLayerIds.add(layerId); + continue; + } + if (liveResult.status !== 'ok') { + return { + status: + liveResult.status === 'aborted' || liveResult.status === 'over-budget' ? liveResult.status : 'not-ready', + }; + } + const live = liveResult; + if (!isLayerExportGuardCurrent(live.guard)) { + live.release(); + return { status: 'stale' }; + } + capturedGuards.push(live.guard); + try { + const actualBytes = live.surface.width * live.surface.height * 4; + const source = renderableSourceOf(layerById.get(layerId)!); + const estimatedBytes = + source?.type === 'image' + ? source.image.width * source.image.height * 4 + : source?.type === 'paint' && source.bitmap + ? source.bitmap.width * source.bitmap.height * 4 + : getSourceContentRect(layerById.get(layerId)!, documentSnapshot.canvas.document).width * + getSourceContentRect(layerById.get(layerId)!, documentSnapshot.canvas.document).height * + 4; + const additionalBytes = Math.max(0, actualBytes - estimatedBytes); + if (additionalBytes > 0) { + rasterController.memory.setBaseBytes(layerCache.byteSize()); + rasterController.memory.setDerivedBytes(derivedSurfaceCache.byteSize()); + const additional = rasterController.memory.reserve(additionalBytes, { + generation: captureLifecycleGeneration, + purpose: 'background-snapshot', + }); + if (additional.status === 'over-budget') { + return { status: 'over-budget' }; + } + reservationLeases.push(additional.lease); + } + const detached = backend.createSurface(live.surface.width, live.surface.height); + detached.ctx.setTransform(1, 0, 0, 1, 0, 0); + detached.ctx.clearRect(0, 0, detached.width, detached.height); + detached.ctx.drawImage(live.surface.canvas, 0, 0); + layerSurfaces.set(layerId, { rect: { ...live.rect }, surface: detached }); + actualDetachedBytes += actualBytes; + } finally { + live.release(); + } + } + if ( + !isDocumentSnapshotCurrent(documentSnapshot) || + capturedGuards.some((guard) => !isLayerExportGuardCurrent(guard)) + ) { + return { status: 'stale' }; + } + + for (const lease of reservationLeases) { + lease.release(); + } + const detachedLease = rasterController.memory.trackDetached(actualDetachedBytes, captureLifecycleGeneration); + let released = false; + const snapshot: CanvasRasterSnapshot = { + ...documentSnapshot, + emptyLayerIds, + layerSurfaces, + release: () => { + if (released) { + return; + } + released = true; + emptyLayerIds.clear(); + layerSurfaces.clear(); + detachedLease.release(); + activeRasterSnapshots.delete(snapshot); + }, + }; + activeRasterSnapshots.add(snapshot); + return { snapshot, status: 'ok' }; + } finally { + for (const lease of reservationLeases) { + lease.release(); + } + for (const lease of pinLeases) { + lease.release(); + } + } + }; + const exportCapability: CanvasEngineExportCapability = { + captureLayerExportGuard: captureCurrentLayerExportGuard, + captureRasterSnapshot, + exportBakedLayerBlob, + exportBakedLayerPixels, + exportLayerPixels: rasterizeLayerPixels, + exportRasterComposite, + exportRasterLayersToPsd, + extractMaskedArea, + getCompositeExecutorDeps, + hasExportableLayerContent, + isLayerExportGuardCurrent, + }; + captureDocumentSnapshot = (): CanvasDocumentSnapshot | null => { + const canvas = mutationPort.getCanvasState(); + if (disposed || !canvas) { + return null; + } + const snapshot: CanvasDocumentSnapshot = { + canvas: structuredClone(canvas), + documentGeneration: rasterController.getDocumentGeneration(), + }; + documentSnapshotSources.set(snapshot, { + canvas, + contentEpoch: rasterContentEpoch, + lifecycleGeneration, + }); + return snapshot; + }; + const documentCapability: CanvasDocumentCapability = { + captureSnapshot: captureDocumentSnapshot, + getDocument: () => mirror.getDocument(), + }; + const selectionCapability: CanvasEngineSelectionCapability = { + deselect, + eraseSelection, + fillSelection, + getSelectionBounds: () => selection.bounds(), + getSelectionMaskRect: () => selection.mask()?.rect ?? null, + invertSelection, + replaceSelectionFromImage, + selectAll, + }; + const toolsCapability: CanvasEngineToolCapability = { + ...interactionController.tools, + contextMenuLayerIdAt, + handleEscapePriority, + onStrokeCommitted, + setInteractionLocked, + }; + const layersCapability: CanvasEngineLayerCapability = { + ...layerController.layers, + applyTransform, + booleanMergeRasterLayers, + cancelTextEdit, + cancelTransform, + clearMask, + commitLayerConversion, + commitLayerCopy, + commitMaskImageResult, + commitOpenTextSession, + commitRasterFilterResult, + commitStagedImage, + commitTextEdit, + copyLayerToRaster, + cropLayerToBbox, + mergeLayerDown, + mergeVisibleRasterLayers, + nudgeSelectedLayer, + openTextCreate, + openTextEdit, + rasterizeLayer, + setTextEditContentReader, + updateTextEditStyle, + updateTransformSession, + }; + const previewCapability: CanvasEnginePreviewCapability = { + ...layerController.previews, + setGuardedFilterPreview, + setStagedPreview, + }; + const diagnosticsCapability: CanvasDiagnosticsCapability = { + clearCaches, + getDiagnostics: diagnostics.snapshot, + logDebugInfo, + }; + + const prepareSelectObjectStart = (layerId: string): SelectObjectStartContext => { + const document = mirror.getDocument(); + const layer = document?.layers.find((candidate) => candidate.id === layerId); + if (!document || !layer) { + return { status: 'missing' }; + } + if (layer.type !== 'raster' && layer.type !== 'control') { + return { status: 'unsupported' }; + } + if (!layer.isEnabled) { + return { status: 'disabled' }; + } + if (layer.isLocked) { + return { status: 'locked' }; + } + const guard = captureCurrentLayerExportGuard(layer.id); + const entry = layerCache.get(layer.id); + if (!guard || !entry) { + return { status: 'not-ready' }; + } + const sourceRect = roundOut( + transformBounds( + fromTRS( + { x: layer.transform.x, y: layer.transform.y }, + layer.transform.rotation, + layer.transform.scaleX, + layer.transform.scaleY + ), + entry.rect + ) + ); + if (isEmpty(sourceRect)) { + return { status: 'not-ready' }; + } + return { guard, layerId, layerName: layer.name, layerType: layer.type, sourceRect, status: 'ready' }; + }; + + const applicationHost: CanvasApplicationHost = { + captureGuard: captureCurrentLayerExportGuard, + clearFilterPreview, + clearSamPreview, + commitFilter: (options) => commitRasterFilterResult(options, documentEditOwner), + commitGenerated: (options) => commitGeneratedImageResult(options, documentEditOwner), + commitMask: (options) => commitMaskImageResult(options, documentEditOwner), + decodeSelectObjectPreview, + encodeSurface: (surface) => backend.encodeSurface(surface, 'image/png'), + exportBakedLayerBlob: (layerId) => exportBakedLayerBlob(layerId, { includeDisabled: true }), + exportLayerPixels: rasterizeLayerPixelsForStructural, + getCompositeExecutorDeps, + getDocument: () => mirror.getDocument(), + isGuardCurrent: isLayerExportGuardCurrent, + isInteractionLocked: () => interactionLocked, + isSamToolActive: () => interactionController.getActiveToolId() === 'sam', + prepareSelectObjectStart, + publishFilterPreview: (layerId, imageName, rect, guard, filterType) => + setGuardedFilterPreview(layerId, { filterType, imageName, rect }, guard), + publishSamPreview: (preview) => { + const isolationChanged = renderController.previews.getSam()?.isolated !== preview.isolated; + renderController.previews.setSam(preview); + scheduler.invalidate(preview.isolated || isolationChanged ? { all: true } : { overlay: true }); + return undefined; + }, + replaceSelection: (guard, image, rect, signal) => + replaceSelectionFromImage(guard, image, rect, signal, documentEditOwner), + replaceTemporaryRestoreTool: () => pipeline.replaceTemporaryRestoreTool('sam', 'view'), + selectLayer: (layerId) => { + if (mirror.getDocument()?.selectedLayerId !== layerId) { + mutationPort.dispatch({ id: layerId, type: 'setCanvasSelectedLayer' }); + } + }, + setSamInputHandler: (handler) => { + samInputHandler = handler; + }, + setEscapeHandler: (handler) => { + applicationEscapeHandler = handler; + }, + setSamInteraction: (state) => { + stores.samInteraction.set(state); + scheduler.invalidate({ overlay: true }); + }, + setSamTool: () => setTool('sam'), + setViewTool: () => setTool('view'), + subscribeToolChanges: (listener) => { + toolChangeListeners.add(listener); + return () => toolChangeListeners.delete(listener); + }, + }; + + const engine: CanvasEngine = { + diagnostics: diagnosticsCapability, + document: documentCapability, + edits: editingController.edits, + exports: exportCapability, + history: historyCapability, + lifecycle, + layers: layersCapability, + projectId, + previews: previewCapability, + selection: selectionCapability, + stores, + surface, + tools: toolsCapability, + viewport: viewportCapability, + }; + return { applicationHost, engine }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts new file mode 100644 index 00000000000..ec0830f9ca4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts @@ -0,0 +1,646 @@ +/** + * Per-engine transient external stores. + * + * These are the narrow, imperative channels React subscribes to (in the widget + * task) to observe engine-owned interaction state — active tool, zoom, + * readiness, cursor, and per-layer thumbnail versions — without the engine ever + * importing React. They follow the `externalStore.ts` pattern (a listener + * channel plus a snapshot getter, `useSyncExternalStore`-compatible) but + * deliberately do NOT import it: `externalStore.ts` pulls in React, and this + * module must stay node-safe and React-free. The React hooks live with the + * widget shell. + * + * Zero React, zero import-time side effects. + */ + +import type { SamInteractionState } from '@workbench/canvas-engine/samInteraction'; +import type { LayerTransform } from '@workbench/canvas-engine/transform/transformMath'; +import type { Rect, SelectionOp, ToolId, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import { type CheckerColors, DEFAULT_CHECKER_COLORS } from '@workbench/canvas-engine/render/compositor'; + +export type { CheckerColors }; + +/** A `text` layer source (content + style params). */ +export type TextSource = Extract; + +/** Brush tool options (document-space size, style, and pressure behavior). */ +export interface BrushOptions { + /** Base stroke diameter in document units. */ + size: number; + /** Fill color (any CSS color string). */ + color: string; + /** Per-stroke opacity in [0, 1]. */ + opacity: number; + /** Whether pen pressure modulates the stroke width. */ + pressureSensitivity: boolean; +} + +/** Eraser tool options. */ +export interface EraserOptions { + /** Base eraser diameter in document units. */ + size: number; + /** Per-stroke erase strength in [0, 1]. */ + opacity: number; +} + +/** Lasso (selection) tool options: the boolean op applied when a path commits. */ +export interface LassoToolOptions { + /** The op a committed lasso path applies to the selection, when no modifier overrides it. */ + mode: SelectionOp; +} + +/** Default lasso options: a fresh path replaces the selection. */ +export const DEFAULT_LASSO_OPTIONS: LassoToolOptions = { + mode: 'replace', +}; + +/** A single gradient stop (offset in [0,1], any CSS color). */ +export interface GradientStop { + offset: number; + color: string; +} + +/** + * Shape tool options: the kind drawn on the next drag, and the fill/stroke + * style applied to it (and, when a shape layer is selected, edited live on it). + * `fill`/`stroke` are `null` for "none". + */ +export interface ShapeToolOptions { + kind: 'rect' | 'ellipse'; + fill: string | null; + stroke: string | null; + strokeWidth: number; +} + +/** Sensible starting shape options: a filled black rect, no stroke. */ +export const DEFAULT_SHAPE_OPTIONS: ShapeToolOptions = { + fill: '#000000', + kind: 'rect', + stroke: null, + strokeWidth: 8, +}; + +/** Largest shape stroke width (document px) the options bar clamps to. */ +export const MAX_SHAPE_STROKE_WIDTH = 2000; + +/** + * Gradient tool options: the kind, the linear angle (degrees), and the stops. + * The minimal two-stop editor edits `stops[0]` (start) and the last stop (end); + * a full multi-stop editor is a follow-up. + */ +export interface GradientToolOptions { + kind: 'linear' | 'radial'; + angle: number; + stops: GradientStop[]; +} + +/** Sensible starting gradient options: black→transparent, horizontal linear. */ +export const DEFAULT_GRADIENT_OPTIONS: GradientToolOptions = { + angle: 0, + kind: 'linear', + stops: [ + { color: '#000000ff', offset: 0 }, + { color: '#00000000', offset: 1 }, + ], +}; + +/** The text style the text tool applies to a newly created layer (and edits live). */ +export interface TextToolOptions { + fontFamily: string; + fontSize: number; + /** CSS numeric weight (400/500/600/700). */ + fontWeight: number; + /** Unitless line-height multiplier over `fontSize`. */ + lineHeight: number; + align: 'left' | 'center' | 'right'; + color: string; +} + +/** + * A small curated font list offered by the text options bar. Values are CSS + * `font-family` stacks so each falls back gracefully; keep it short this phase + * (a full system-font enumeration is a follow-up). + */ +export const TEXT_FONT_FAMILIES: readonly { label: string; value: string }[] = [ + { label: 'Inter', value: "'Inter Variable', Inter, sans-serif" }, + { label: 'Sans-serif', value: 'system-ui, sans-serif' }, + { label: 'Serif', value: "Georgia, 'Times New Roman', serif" }, + { label: 'Monospace', value: "'JetBrains Mono', ui-monospace, monospace" }, +]; + +/** Weights the text options bar offers. */ +export const TEXT_FONT_WEIGHTS: readonly number[] = [400, 500, 600, 700]; + +/** Smallest / largest font size (document px) the text options bar clamps to. */ +export const MIN_TEXT_FONT_SIZE = 1; +export const MAX_TEXT_FONT_SIZE = 2000; + +/** Sensible starting text options: black left-aligned Inter at 48px. */ +export const DEFAULT_TEXT_OPTIONS: TextToolOptions = { + align: 'left', + color: '#000000', + fontFamily: TEXT_FONT_FAMILIES[0]!.value, + fontSize: 48, + fontWeight: 400, + lineHeight: 1.2, +}; + +/** Bbox (generation-frame) tool options: the aspect-ratio lock. */ +export interface BboxToolOptions { + /** Whether corner/edge resize preserves {@link BboxToolOptions.aspectRatio}. */ + aspectLocked: boolean; + /** The locked width / height ratio. */ + aspectRatio: number; +} + +/** Default grid size (document px) the bbox snaps to before a model feeds a real one. */ +export const DEFAULT_BBOX_GRID = 8; + +/** Default bbox tool options: aspect unlocked, square ratio. */ +export const DEFAULT_BBOX_OPTIONS: BboxToolOptions = { + aspectLocked: false, + aspectRatio: 1, +}; + +/** Smallest and largest brush/eraser diameters (document units) the size step clamps to. */ +export const MIN_BRUSH_SIZE = 1; +export const MAX_BRUSH_SIZE = 2000; + +/** Sensible starting brush options. */ +export const DEFAULT_BRUSH_OPTIONS: BrushOptions = { + color: '#000000', + opacity: 1, + pressureSensitivity: true, + size: 50, +}; + +/** Sensible starting eraser options. */ +export const DEFAULT_ERASER_OPTIONS: EraserOptions = { + opacity: 1, + size: 50, +}; + +/** + * An active transform-tool session on one layer. Outlives individual pointer + * gestures (drag handles, adjust numerics) until Apply or Cancel. `startTransform` + * is the committed transform captured at session start (restored on Cancel / + * used as the undo inverse); `transform` is the live, edited transform the + * compositor previews and the options bar renders as numerics. + */ +export interface TransformSession { + layerId: string; + startTransform: LayerTransform; + transform: LayerTransform; +} + +/** + * An active text-editing session. Set by the text tool; while it is active the + * contenteditable portal (in `widgets/canvas`) shows the live text and the + * compositor SKIPS the session's layer (edit mode) so the two don't double-draw. + * + * Two modes: + * - **create**: no layer exists yet (`layerId === null`, `startSource === null`). + * Commit dispatches ONE `addCanvasLayer` with the final content; cancel adds + * nothing. This keeps a new text layer to a single, cleanly-undoable commit. + * - **edit**: an existing text layer is being re-edited. `startSource` is its + * committed source (the exact undo inverse / no-change baseline); `source` is + * the live, style-edited source. Commit dispatches ONE `updateCanvasLayerSource`. + * + * `source` carries the live style (font/size/weight/lineHeight/align/color) the + * portal renders WYSIWYG and the options bar edits; `content` on it is only the + * seed — the live typed content lives in the contenteditable DOM until commit. + * `transform` positions/scales the portal (document→screen via the view matrix). + * `id` increments per session so React can key (remount) the editable per open. + */ +export interface TextEditSession { + id: number; + mode: 'create' | 'edit'; + layerId: string | null; + startSource: TextSource | null; + source: TextSource; + transform: LayerTransform; +} + +/** A single-value store, `useSyncExternalStore`-compatible. */ +export interface ScalarStore { + get(): T; + set(next: T): void; + subscribe(listener: () => void): () => void; +} + +const createScalarStore = (initial: T, isEqual: (a: T, b: T) => boolean = Object.is): ScalarStore => { + let value = initial; + const listeners = new Set<() => void>(); + + return { + get: () => value, + set: (next) => { + if (isEqual(value, next)) { + return; + } + value = next; + for (const listener of listeners) { + listener(); + } + }, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +}; + +/** + * A keyed numeric store with per-key subscription granularity, so a React + * component watching one layer's thumbnail version only re-renders when that + * layer changes. A global `subscribe` is also exposed for coarse observers. + */ +export interface KeyedVersionStore { + get(key: string): number | undefined; + set(key: string, value: number): void; + delete(key: string): void; + /** Subscribes to changes for a single key. */ + subscribeKey(key: string, listener: () => void): () => void; + /** Subscribes to any change across all keys. */ + subscribe(listener: () => void): () => void; +} + +export type LayerThumbnailStatus = 'loading' | 'ready' | 'error'; + +/** A per-layer thumbnail request state; absence represents `idle`. */ +export interface KeyedThumbnailStatusStore { + get(key: string): LayerThumbnailStatus | undefined; + set(key: string, value: LayerThumbnailStatus): void; + delete(key: string): void; + clear(): void; + subscribeKey(key: string, listener: () => void): () => void; +} + +const createKeyedVersionStore = (): KeyedVersionStore => { + const values = new Map(); + const keyedListeners = new Map void>>(); + const globalListeners = new Set<() => void>(); + + const notify = (key: string): void => { + for (const listener of keyedListeners.get(key) ?? []) { + listener(); + } + for (const listener of globalListeners) { + listener(); + } + }; + + return { + delete: (key) => { + if (values.delete(key)) { + notify(key); + } + }, + get: (key) => values.get(key), + set: (key, value) => { + if (values.get(key) === value) { + return; + } + values.set(key, value); + notify(key); + }, + subscribe: (listener) => { + globalListeners.add(listener); + return () => { + globalListeners.delete(listener); + }; + }, + subscribeKey: (key, listener) => { + const listeners = keyedListeners.get(key) ?? new Set<() => void>(); + listeners.add(listener); + keyedListeners.set(key, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + keyedListeners.delete(key); + } + }; + }, + }; +}; + +const createKeyedThumbnailStatusStore = (): KeyedThumbnailStatusStore => { + const values = new Map(); + const keyedListeners = new Map void>>(); + const notify = (key: string): void => { + for (const listener of keyedListeners.get(key) ?? []) { + listener(); + } + }; + + return { + clear: () => { + const keys = [...values.keys()]; + values.clear(); + for (const key of keys) { + notify(key); + } + }, + delete: (key) => { + if (values.delete(key)) { + notify(key); + } + }, + get: (key) => values.get(key), + set: (key, value) => { + if (values.get(key) === value) { + return; + } + values.set(key, value); + notify(key); + }, + subscribeKey: (key, listener) => { + const listeners = keyedListeners.get(key) ?? new Set<() => void>(); + listeners.add(listener); + keyedListeners.set(key, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + keyedListeners.delete(key); + } + }; + }, + }; +}; + +/** The bundle of transient stores owned by one engine instance. */ +export interface EngineStores { + activeTool: ScalarStore; + zoom: ScalarStore; + viewportReady: ScalarStore; + cursor: ScalarStore; + thumbnailVersion: KeyedVersionStore; + thumbnailStatus: KeyedThumbnailStatusStore; + /** Brush tool options (size / color / opacity / pressure). */ + brushOptions: ScalarStore; + /** Eraser tool options (size / opacity). */ + eraserOptions: ScalarStore; + /** Whether the engine-owned canvas history has an entry to undo. */ + canUndo: ScalarStore; + /** Whether the engine-owned canvas history has an entry to redo. */ + canRedo: ScalarStore; + /** Bbox tool options (aspect lock / ratio). */ + bboxOptions: ScalarStore; + /** Lasso tool options (the committed boolean op mode). */ + lassoOptions: ScalarStore; + /** Shape tool options (kind / fill / stroke / stroke width). */ + shapeOptions: ScalarStore; + /** Gradient tool options (kind / angle / stops). */ + gradientOptions: ScalarStore; + /** Text tool options (font family / size / weight / line-height / align / color). */ + textOptions: ScalarStore; + /** + * The active text-editing session, or `null`. React reads it to render the + * contenteditable portal and enable the text options bar's live-restyle path; + * the engine reads it to skip the session layer in the composite. Cleared on + * commit, cancel, real tool switch, layer delete, or document replace. + */ + textEditSession: ScalarStore; + /** + * The live shape-tool drag preview (document-space rect + kind), or `null` + * when idle. The overlay renders the shape outline in place of a committed + * layer so the drag tracks without dispatching; cleared on commit/cancel. + */ + shapePreview: ScalarStore<{ rect: Rect; kind: 'rect' | 'ellipse' } | null>; + /** + * The live gradient-tool drag preview: the drag vector's start/end points in + * document space, drawn on the overlay as a direction indicator (a gradient + * necessarily fills the document, so only its ANGLE is previewed, not a + * bounding box). `null` when idle; cleared on commit/cancel. + */ + gradientPreview: ScalarStore<{ start: Vec2; end: Vec2 } | null>; + /** + * Whether a pixel selection currently exists. React reads it to enable the + * fill/erase/invert/deselect controls and the engine gates selection hotkeys + * and marching-ants animation off it. The engine writes it as the selection + * mask gains/loses content. + */ + hasSelection: ScalarStore; + /** Core-only visual SAM interaction state; application session status remains outside the engine. */ + samInteraction: ScalarStore; + /** + * The in-progress lasso polygon (document-space points) during a lasso drag, + * or `null` when idle. The overlay renders it as a live dashed preview in + * place of a committed selection; cleared on commit/cancel. Like `bboxPreview`, + * it is a transient channel — no dispatch, no React subscriber. + */ + lassoPreview: ScalarStore; + /** Model-dependent grid size (document px) the bbox snaps to. React feeds this from generate settings. */ + bboxGrid: ScalarStore; + /** + * The live bbox preview rect during a bbox-tool gesture (document space), or + * `null` when idle. The overlay renders this in place of the committed bbox so + * the frame tracks the drag without dispatching; cleared on commit/cancel. + */ + bboxPreview: ScalarStore; + /** + * The active transform-tool session (layer id + start/live transform), or + * `null` when no session is open. React reads it to render the numeric options + * and enable Apply/Cancel; the engine drives the live preview from it. Cleared + * on Apply, Cancel, tool switch, or document replace. + */ + transformSession: ScalarStore; + /** + * Whether the transparency checkerboard is drawn behind transparent documents + * (default ON). Off shows the widget surface through the document instead. The + * compositor reads this each frame; the canvas settings menu toggles it. + */ + checkerboard: ScalarStore; + /** + * The two square colors of the transparency checkerboard, resolved from Chakra + * semantic tokens in React and fed down (see `widgets/canvas/checkerColors.ts`). + * The engine rebuilds its cached checker tile and recomposites when these + * change (e.g. a theme/color-mode switch); {@link DEFAULT_CHECKER_COLORS} is the + * React-free fallback until the first feed. + */ + checkerColors: ScalarStore; + /** + * Whether the document-space grid (at the bbox snap size) is drawn on the + * overlay (default OFF). The overlay reads this each frame; the canvas settings + * menu toggles it. + */ + showGrid: ScalarStore; + /** + * Whether ctrl+wheel brush/eraser sizing is inverted (default OFF): normally + * wheel-up grows the size. The wheel handler reads this; the canvas settings + * menu toggles it. Purely an input preference — no render effect. + */ + invertBrushSizeScroll: ScalarStore; + /** + * Whether the generation bbox (dashed frame) is drawn as passive overlay chrome + * (default ON). The overlay reads this each frame; the canvas settings popover + * toggles it. The bbox is still drawn (with its handles) while the bbox TOOL is + * active regardless, so it stays editable — this only hides the passive frame. + */ + showBbox: ScalarStore; + /** + * Whether the bbox overlay shade is drawn (default OFF): a translucent dark + * fill over everything OUTSIDE the bbox, focusing attention on the generation + * region (legacy `CanvasBboxToolModule` overlayRect parity). Overlay-only — + * toggling never recomposites the document. + */ + bboxOverlay: ScalarStore; + /** + * Whether the rule-of-thirds composition guide (two vertical + two horizontal + * lines dividing the bbox into thirds) is drawn inside the bbox (default OFF). + * The overlay reads this each frame; the canvas settings popover toggles it. + */ + ruleOfThirds: ScalarStore; + /** + * Whether bbox-tool moves/resizes snap to the model grid (default ON). The bbox + * tool reads this; the canvas settings popover toggles it, and the fit-bbox + * header actions honor it too. Holding Alt bypasses snapping independently. + * Purely an interaction preference — no render effect. + */ + snapToGrid: ScalarStore; + /** Whether an engine-owned operation currently excludes ordinary document edits. */ + documentEditingLocked: ScalarStore; +} + +const brushOptionsEqual = (a: BrushOptions, b: BrushOptions): boolean => + a.size === b.size && + a.color === b.color && + a.opacity === b.opacity && + a.pressureSensitivity === b.pressureSensitivity; + +const eraserOptionsEqual = (a: EraserOptions, b: EraserOptions): boolean => + a.size === b.size && a.opacity === b.opacity; + +const checkerColorsEqual = (a: CheckerColors, b: CheckerColors): boolean => a.a === b.a && a.b === b.b; + +const bboxOptionsEqual = (a: BboxToolOptions, b: BboxToolOptions): boolean => + a.aspectLocked === b.aspectLocked && a.aspectRatio === b.aspectRatio; + +const lassoOptionsEqual = (a: LassoToolOptions, b: LassoToolOptions): boolean => a.mode === b.mode; + +const shapeOptionsEqual = (a: ShapeToolOptions, b: ShapeToolOptions): boolean => + a.kind === b.kind && a.fill === b.fill && a.stroke === b.stroke && a.strokeWidth === b.strokeWidth; + +const stopsEqual = (a: readonly GradientStop[], b: readonly GradientStop[]): boolean => + a.length === b.length && a.every((stop, i) => stop.offset === b[i]?.offset && stop.color === b[i]?.color); + +const gradientOptionsEqual = (a: GradientToolOptions, b: GradientToolOptions): boolean => + a.kind === b.kind && a.angle === b.angle && stopsEqual(a.stops, b.stops); + +const shapePreviewEqual = ( + a: { rect: Rect; kind: 'rect' | 'ellipse' } | null, + b: { rect: Rect; kind: 'rect' | 'ellipse' } | null +): boolean => { + if (a === null || b === null) { + return a === b; + } + return ( + a.kind === b.kind && + a.rect.x === b.rect.x && + a.rect.y === b.rect.y && + a.rect.width === b.rect.width && + a.rect.height === b.rect.height + ); +}; + +const gradientPreviewEqual = (a: { start: Vec2; end: Vec2 } | null, b: { start: Vec2; end: Vec2 } | null): boolean => { + if (a === null || b === null) { + return a === b; + } + return a.start.x === b.start.x && a.start.y === b.start.y && a.end.x === b.end.x && a.end.y === b.end.y; +}; + +const bboxPreviewEqual = (a: Rect | null, b: Rect | null): boolean => { + if (a === null || b === null) { + return a === b; + } + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; +}; + +const transformEqual = (a: LayerTransform, b: LayerTransform): boolean => + a.x === b.x && a.y === b.y && a.scaleX === b.scaleX && a.scaleY === b.scaleY && a.rotation === b.rotation; + +const transformSessionEqual = (a: TransformSession | null, b: TransformSession | null): boolean => { + if (a === null || b === null) { + return a === b; + } + return ( + a.layerId === b.layerId && + transformEqual(a.startTransform, b.startTransform) && + transformEqual(a.transform, b.transform) + ); +}; + +const textOptionsEqual = (a: TextToolOptions, b: TextToolOptions): boolean => + a.fontFamily === b.fontFamily && + a.fontSize === b.fontSize && + a.fontWeight === b.fontWeight && + a.lineHeight === b.lineHeight && + a.align === b.align && + a.color === b.color; + +const textSourceEqual = (a: TextSource, b: TextSource): boolean => + a.content === b.content && + a.fontFamily === b.fontFamily && + a.fontSize === b.fontSize && + a.fontWeight === b.fontWeight && + a.lineHeight === b.lineHeight && + a.align === b.align && + a.color === b.color; + +const textEditSessionEqual = (a: TextEditSession | null, b: TextEditSession | null): boolean => { + if (a === null || b === null) { + return a === b; + } + return ( + a.id === b.id && + a.mode === b.mode && + a.layerId === b.layerId && + textSourceEqual(a.source, b.source) && + transformEqual(a.transform, b.transform) + ); +}; + +/** Creates a fresh bundle of engine stores with their initial values. */ +export const createEngineStores = (initialTool: ToolId = 'view'): EngineStores => ({ + activeTool: createScalarStore(initialTool), + bboxGrid: createScalarStore(DEFAULT_BBOX_GRID), + bboxOptions: createScalarStore({ ...DEFAULT_BBOX_OPTIONS }, bboxOptionsEqual), + bboxPreview: createScalarStore(null, bboxPreviewEqual), + bboxOverlay: createScalarStore(false), + brushOptions: createScalarStore({ ...DEFAULT_BRUSH_OPTIONS }, brushOptionsEqual), + canRedo: createScalarStore(false), + canUndo: createScalarStore(false), + checkerboard: createScalarStore(true), + checkerColors: createScalarStore({ ...DEFAULT_CHECKER_COLORS }, checkerColorsEqual), + cursor: createScalarStore('default'), + eraserOptions: createScalarStore({ ...DEFAULT_ERASER_OPTIONS }, eraserOptionsEqual), + documentEditingLocked: createScalarStore(false), + hasSelection: createScalarStore(false), + invertBrushSizeScroll: createScalarStore(false), + gradientOptions: createScalarStore( + { ...DEFAULT_GRADIENT_OPTIONS, stops: DEFAULT_GRADIENT_OPTIONS.stops.map((s) => ({ ...s })) }, + gradientOptionsEqual + ), + gradientPreview: createScalarStore<{ start: Vec2; end: Vec2 } | null>(null, gradientPreviewEqual), + lassoOptions: createScalarStore({ ...DEFAULT_LASSO_OPTIONS }, lassoOptionsEqual), + lassoPreview: createScalarStore(null), + ruleOfThirds: createScalarStore(false), + samInteraction: createScalarStore(null), + shapeOptions: createScalarStore({ ...DEFAULT_SHAPE_OPTIONS }, shapeOptionsEqual), + shapePreview: createScalarStore<{ rect: Rect; kind: 'rect' | 'ellipse' } | null>(null, shapePreviewEqual), + showBbox: createScalarStore(true), + showGrid: createScalarStore(false), + snapToGrid: createScalarStore(true), + textEditSession: createScalarStore(null, textEditSessionEqual), + textOptions: createScalarStore({ ...DEFAULT_TEXT_OPTIONS }, textOptionsEqual), + thumbnailVersion: createKeyedVersionStore(), + thumbnailStatus: createKeyedThumbnailStatusStore(), + transformSession: createScalarStore(null, transformSessionEqual), + viewportReady: createScalarStore(false), + zoom: createScalarStore(1), +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/export/psdExport.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/export/psdExport.test.ts new file mode 100644 index 00000000000..5881ed568c9 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/export/psdExport.test.ts @@ -0,0 +1,165 @@ +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasBlendMode } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import type { PsdExportLayerInput } from './psdExport'; + +import { blendModeToPsd, planPsdExport, PSD_MAX_DIMENSION } from './psdExport'; + +const IDENTITY = { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }; + +const layer = (over: Partial = {}): PsdExportLayerInput => ({ + blendMode: 'normal', + contentRect: { height: 50, width: 100, x: 0, y: 0 }, + id: 'a', + isEnabled: true, + name: 'Layer', + opacity: 1, + transform: { ...IDENTITY }, + ...over, +}); + +const findLayer = (plan: ReturnType, id: string) => { + if (plan.status !== 'ok') { + throw new Error(`expected ok plan, got ${plan.status}`); + } + const found = plan.layers.find((l) => l.id === id); + if (!found) { + throw new Error(`layer ${id} not in plan`); + } + return found; +}; + +describe('blendModeToPsd', () => { + it('maps every canvas blend mode to a PSD blend key', () => { + const cases: [CanvasBlendMode, string][] = [ + ['normal', 'normal'], + ['multiply', 'multiply'], + ['screen', 'screen'], + ['overlay', 'overlay'], + ['darken', 'darken'], + ['lighten', 'lighten'], + ['color-dodge', 'color dodge'], + ['color-burn', 'color burn'], + ['hard-light', 'hard light'], + ['soft-light', 'soft light'], + ['difference', 'difference'], + ['exclusion', 'exclusion'], + ['hue', 'hue'], + ['saturation', 'saturation'], + ['color', 'color'], + ['luminosity', 'luminosity'], + ]; + for (const [mode, key] of cases) { + expect(blendModeToPsd(mode)).toBe(key); + } + }); + + it('falls back to normal for an unknown blend mode', () => { + expect(blendModeToPsd('made-up' as CanvasBlendMode)).toBe('normal'); + }); +}); + +describe('planPsdExport', () => { + it('returns empty when there are no layers', () => { + expect(planPsdExport([])).toEqual({ status: 'empty' }); + }); + + it('returns empty when every layer has empty content', () => { + expect(planPsdExport([layer({ contentRect: { height: 0, width: 0, x: 0, y: 0 } })])).toEqual({ + status: 'empty', + }); + }); + + it('sizes the PSD canvas to a single layer content bounds', () => { + const plan = planPsdExport([layer()]); + expect(plan.status).toBe('ok'); + if (plan.status !== 'ok') { + return; + } + expect(plan.width).toBe(100); + expect(plan.height).toBe(50); + expect(plan.canvasRect).toEqual({ height: 50, width: 100, x: 0, y: 0 }); + const only = plan.layers[0]!; + expect(only).toMatchObject({ bottom: 50, hidden: false, left: 0, opacity: 1, right: 100, top: 0 }); + }); + + it('unions world-space bounds across layers and positions each relative to the origin', () => { + const plan = planPsdExport([ + layer({ id: 'top', transform: { ...IDENTITY, x: 50, y: 20 } }), + layer({ id: 'bottom', transform: { ...IDENTITY, x: -30, y: -10 } }), + ]); + if (plan.status !== 'ok') { + throw new Error('expected ok'); + } + // union of [-30,-10,100,50] and [50,20,100,50] = [-30,-10, 180, 80] + expect(plan.canvasRect).toEqual({ height: 80, width: 180, x: -30, y: -10 }); + expect(plan.width).toBe(180); + expect(plan.height).toBe(80); + // positions are relative to the union origin (-30, -10) + expect(findLayer(plan, 'bottom')).toMatchObject({ left: 0, top: 0, right: 100, bottom: 50 }); + expect(findLayer(plan, 'top')).toMatchObject({ left: 80, top: 30, right: 180, bottom: 80 }); + }); + + it('emits layers bottom-to-top (input is top-first, PSD children are bottom-first)', () => { + const plan = planPsdExport([layer({ id: 'top' }), layer({ id: 'mid' }), layer({ id: 'bottom' })]); + if (plan.status !== 'ok') { + throw new Error('expected ok'); + } + expect(plan.layers.map((l) => l.id)).toEqual(['bottom', 'mid', 'top']); + }); + + it('marks disabled layers hidden but still exports them, and clamps opacity to 0..1', () => { + const plan = planPsdExport([ + layer({ id: 'shown', isEnabled: true, opacity: 0.5 }), + layer({ id: 'over', isEnabled: true, opacity: 2 }), + layer({ id: 'hidden', isEnabled: false, opacity: -1 }), + ]); + expect(findLayer(plan, 'shown')).toMatchObject({ hidden: false, opacity: 0.5 }); + expect(findLayer(plan, 'over')).toMatchObject({ opacity: 1 }); + expect(findLayer(plan, 'hidden')).toMatchObject({ hidden: true, opacity: 0 }); + }); + + it('maps blend modes and reports unmapped ones (falling back to normal)', () => { + const plan = planPsdExport([ + layer({ blendMode: 'multiply', id: 'a' }), + layer({ blendMode: 'nonsense' as CanvasBlendMode, id: 'b' }), + ]); + if (plan.status !== 'ok') { + throw new Error('expected ok'); + } + expect(findLayer(plan, 'a').blendMode).toBe('multiply'); + expect(findLayer(plan, 'b').blendMode).toBe('normal'); + expect(plan.unmappedBlends).toEqual(['nonsense']); + }); + + it('passes non-destructive adjustments through for the executor to bake', () => { + const adjustments = { brightness: 0.2, contrast: 0, saturation: 0 }; + const plan = planPsdExport([layer({ adjustments })]); + expect(findLayer(plan, 'a').adjustments).toBe(adjustments); + }); + + it('drops empty-content layers from the plan but keeps the rest', () => { + const plan = planPsdExport([ + layer({ contentRect: { height: 0, width: 0, x: 0, y: 0 }, id: 'empty' }), + layer({ id: 'real' }), + ]); + if (plan.status !== 'ok') { + throw new Error('expected ok'); + } + expect(plan.layers.map((l) => l.id)).toEqual(['real']); + }); + + it('refuses an export whose union bounds exceed the dimension cap', () => { + const plan = planPsdExport([layer({ contentRect: { height: 10, width: 100, x: 0, y: 0 } })], { + maxDimension: 50, + }); + expect(plan).toEqual({ height: 10, status: 'too-large', width: 100 }); + }); + + it('accepts bounds exactly at the cap', () => { + const plan = planPsdExport([layer({ contentRect: { height: 10, width: PSD_MAX_DIMENSION, x: 0, y: 0 } })]); + expect(plan.status).toBe('ok'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/export/psdExport.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/export/psdExport.ts new file mode 100644 index 00000000000..94ca2a06ba0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/export/psdExport.ts @@ -0,0 +1,413 @@ +/** + * Export raster layers to a Photoshop (.psd) document. + * + * Split into a PURE planner and an IMPURE executor, mirroring the + * planner/executor split of `compositeForGeneration.ts`: + * + * - {@link planPsdExport} is pure geometry (no DOM, no `ag-psd`, no engine): it + * turns each raster layer's transform + content rect into a PSD layer entry + * (position, opacity, blend, hidden, order) and the document bounds. Unit + * tested in node. + * - {@link executePsdExport} is the side-effecting half: it bakes each layer's + * pixels through the {@link RasterBackend} seam, lazily imports `ag-psd` + * (`writePsd`) at call time so the library never enters the main bundle, and + * triggers a browser download. Verified by types + manual QA (opening the PSD). + * + * ### Conventions + * - **Order.** The canvas document stores layers top-first (index 0 = top-most). + * ag-psd's `children` array is BOTTOM-to-top (`children[0]` is the bottom-most + * layer, written first to the PSD layer records, which the format stores + * bottom-up). So the plan reverses the top-first input into bottom-to-top. + * - **Bounds.** The PSD canvas is the union of every EXPORTED layer's + * world-space (document-space) content AABB — document/bbox-independent. An + * empty union means nothing to export. + * - **Opacity.** ag-psd's `Layer.opacity` is 0..1 (the writer multiplies by 255 + * internally), NOT 0..255. Our `layer.opacity` is already 0..1, so it passes + * through unchanged (clamped). + * - **Hidden.** Every raster layer with content is exported; hidden (disabled) + * layers are written with `hidden: true` rather than dropped. + * - **Adjustments.** Non-destructive raster adjustments are BAKED into the + * layer's pixels (PSD has no matching non-destructive representation we emit), + * exactly as `compositeForGeneration` bakes them, so the PSD matches what the + * user sees. Opacity/blend stay as PSD layer properties (not baked). + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Mat2d, Rect } from '@workbench/canvas-engine/types'; +import type { CanvasAdjustmentsContract, CanvasBlendMode } from '@workbench/types'; +import type { BlendMode, Layer as AgPsdLayer, Psd } from 'ag-psd'; + +import { fromTRS } from '@workbench/canvas-engine/math/mat2d'; +import { isEmpty, roundOut, transformBounds, union } from '@workbench/canvas-engine/math/rect'; +import { applyAdjustments } from '@workbench/canvas-engine/render/adjustments'; +import { blendToComposite } from '@workbench/canvas-engine/render/compositor'; + +/** + * Maximum PSD side length. ag-psd/Photoshop tolerate up to 300000px, but a + * multi-gigabyte export from an unbounded-canvas union helps nobody — refuse + * past a sane cap and tell the user. Legacy Photoshop's own PSD limit is 30000. + */ +export const PSD_MAX_DIMENSION = 30000; + +/** A canvas layer transform (TRS), duplicated to keep this module contract-light. */ +export interface PsdLayerTransform { + x: number; + y: number; + scaleX: number; + scaleY: number; + rotation: number; +} + +/** + * Maps a document blend mode to ag-psd's blend key. Every blend mode the canvas + * supports has a direct PSD equivalent (Photoshop is the origin of these modes), + * so this is total; an unknown value falls back to 'normal' and is reported via + * {@link PsdExportOk.unmappedBlends}. + */ +const BLEND_MODE_TO_PSD: Record = { + color: 'color', + 'color-burn': 'color burn', + 'color-dodge': 'color dodge', + darken: 'darken', + difference: 'difference', + exclusion: 'exclusion', + 'hard-light': 'hard light', + hue: 'hue', + lighten: 'lighten', + luminosity: 'luminosity', + multiply: 'multiply', + normal: 'normal', + overlay: 'overlay', + saturation: 'saturation', + screen: 'screen', + 'soft-light': 'soft light', +}; + +/** The ag-psd blend key for a document blend mode ('normal' for anything unmapped). */ +export const blendModeToPsd = (mode: CanvasBlendMode): BlendMode => BLEND_MODE_TO_PSD[mode] ?? 'normal'; + +/** One raster layer's export-relevant facts, in the document's top-first order. */ +export interface PsdExportLayerInput { + id: string; + name: string; + transform: PsdLayerTransform; + /** The layer's content rect in LOCAL space (origin may be negative). */ + contentRect: Rect; + /** 0..1. */ + opacity: number; + blendMode: CanvasBlendMode; + /** Hidden (disabled) layers are exported with `hidden: true`, not dropped. */ + isEnabled: boolean; + /** Non-destructive adjustments to bake into the layer's pixels, if any. */ + adjustments?: CanvasAdjustmentsContract; +} + +/** A single planned PSD layer (already in ag-psd bottom-to-top order). */ +export interface PsdPlanLayer { + id: string; + name: string; + /** Position within the PSD canvas (relative to the union origin). */ + left: number; + top: number; + right: number; + bottom: number; + /** The layer's world-space AABB (document space) the executor bakes into. */ + worldRect: Rect; + transform: PsdLayerTransform; + /** Layer-local content rect (executor draws the cache at its origin). */ + contentRect: Rect; + /** 0..1 (ag-psd's range). */ + opacity: number; + blendMode: BlendMode; + /** Canvas `globalCompositeOperation` for the flattened composite preview. */ + compositeBlend: GlobalCompositeOperation; + hidden: boolean; + adjustments?: CanvasAdjustmentsContract; +} + +/** A successful export plan. */ +export interface PsdExportOk { + status: 'ok'; + /** PSD canvas dimensions (= union bounds). */ + width: number; + height: number; + /** The union bounds in document space (origin is the PSD's (0,0)). */ + canvasRect: Rect; + /** Layers in ag-psd order (bottom-to-top). */ + layers: PsdPlanLayer[]; + /** Distinct blend modes that had no PSD equivalent (fell back to 'normal'). */ + unmappedBlends: string[]; +} + +/** The plan, or a refusal (`empty` / `too-large`). */ +export type PsdExportPlan = PsdExportOk | { status: 'empty' } | { status: 'too-large'; width: number; height: number }; + +/** Options for {@link planPsdExport}. */ +export interface PlanPsdExportOptions { + /** Override the per-side dimension cap (default {@link PSD_MAX_DIMENSION}). */ + maxDimension?: number; +} + +const layerMatrix = (t: PsdLayerTransform): Mat2d => fromTRS({ x: t.x, y: t.y }, t.rotation, t.scaleX, t.scaleY); + +/** Clamps to [0, 1] (defensive against out-of-range opacities). */ +const clamp01 = (value: number): number => (value < 0 ? 0 : value > 1 ? 1 : value); + +/** + * Plans a PSD export from raster layers (top-first). Computes each layer's + * world-space AABB, unions them for the PSD canvas, and produces per-layer PSD + * entries in bottom-to-top order. Layers with no content (empty rect, or a + * degenerate zero-area transform) contribute nothing and are omitted. Returns + * `empty` when nothing has content and `too-large` when the union exceeds the + * dimension cap. + */ +export const planPsdExport = ( + inputs: readonly PsdExportLayerInput[], + options: PlanPsdExportOptions = {} +): PsdExportPlan => { + const maxDimension = options.maxDimension ?? PSD_MAX_DIMENSION; + + // World-space AABB per layer (null = no content: empty local rect or a + // zero-area transform that collapses the bounds). + const withBounds = inputs.map((input) => { + if (isEmpty(input.contentRect)) { + return { input, worldRect: null as Rect | null }; + } + const worldRect = roundOut(transformBounds(layerMatrix(input.transform), input.contentRect)); + return { input, worldRect: isEmpty(worldRect) ? null : worldRect }; + }); + + let bounds: Rect | null = null; + for (const { worldRect } of withBounds) { + if (worldRect) { + bounds = bounds === null ? worldRect : union(bounds, worldRect); + } + } + if (bounds === null || isEmpty(bounds)) { + return { status: 'empty' }; + } + const canvasRect = roundOut(bounds); + if (canvasRect.width > maxDimension || canvasRect.height > maxDimension) { + return { height: canvasRect.height, status: 'too-large', width: canvasRect.width }; + } + + const unmappedBlends = new Set(); + // ag-psd order is bottom-to-top; inputs are top-first, so reverse. Layers + // without content are dropped. + const layers: PsdPlanLayer[] = []; + for (let i = withBounds.length - 1; i >= 0; i -= 1) { + const { input, worldRect } = withBounds[i]!; + if (!worldRect) { + continue; + } + const mapped = BLEND_MODE_TO_PSD[input.blendMode]; + if (!mapped) { + unmappedBlends.add(input.blendMode); + } + const left = worldRect.x - canvasRect.x; + const top = worldRect.y - canvasRect.y; + layers.push({ + adjustments: input.adjustments, + blendMode: mapped ?? 'normal', + bottom: top + worldRect.height, + compositeBlend: blendToComposite(input.blendMode), + contentRect: input.contentRect, + hidden: !input.isEnabled, + id: input.id, + left, + name: input.name, + opacity: clamp01(input.opacity), + right: left + worldRect.width, + top, + transform: input.transform, + worldRect, + }); + } + + return { + canvasRect, + height: canvasRect.height, + layers, + status: 'ok', + unmappedBlends: [...unmappedBlends], + width: canvasRect.width, + }; +}; + +// ---- Executor (impure) ----------------------------------------------------- + +type Ctx = RasterSurface['ctx']; + +/** Reads a surface region's pixels (real DOM path; injectable for tests). */ +const defaultReadImageData = (surface: RasterSurface, rect: Rect): ImageData => + surface.ctx.getImageData(rect.x, rect.y, rect.width, rect.height); + +/** Writes pixels back to a surface (real DOM path; injectable for tests). */ +const defaultWriteImageData = (surface: RasterSurface, imageData: ImageData, x: number, y: number): void => + surface.ctx.putImageData(imageData, x, y); + +/** + * Serializes a {@link Psd} to bytes via a LAZILY-imported `ag-psd`, so the + * library is never pulled into the main bundle (Vite code-splits the dynamic + * import into its own chunk, loaded only when an export runs). + */ +const defaultWritePsd = async (psd: Psd): Promise => { + const { writePsd } = await import('ag-psd'); + return writePsd(psd); +}; + +/** Triggers a browser download of the PSD bytes (Blob + anchor click). */ +const defaultDownload = (data: ArrayBuffer, fileName: string): void => { + const blob = new Blob([data], { type: 'image/vnd.adobe.photoshop' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +}; + +/** Injected dependencies for {@link executePsdExport}. */ +export interface ExecutePsdExportDeps { + /** Surface factory (usually the engine's `RasterBackend`). */ + backend: { createSurface(width: number, height: number): RasterSurface }; + /** Cancels before the next background allocation or side effect. */ + signal?: AbortSignal; + /** + * Ensures a layer's cache is rasterized and returns its surface plus the + * content `rect` (layer-local origin/size) those pixels occupy. The engine + * wires this to its rasterize path (reading live paint caches when present). + */ + getLayerSurface(layerId: string): Promise<{ surface: RasterSurface; rect: Rect }>; + /** Reads a surface region's pixels (default `getImageData`). */ + readImageData?(surface: RasterSurface, rect: Rect): ImageData; + /** Writes pixels back to a surface (default `putImageData`). */ + writeImageData?(surface: RasterSurface, imageData: ImageData, x: number, y: number): void; + /** Serializes a PSD to bytes (default: lazy `ag-psd` `writePsd`). */ + writePsd?(psd: Psd): Promise; + /** Triggers the download (default: Blob + anchor click). */ + download?(data: ArrayBuffer, fileName: string): void; +} + +/** Sets a 2D context transform from a matrix. */ +const setTransform = (ctx: Ctx, m: Mat2d): void => { + ctx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f); +}; + +const throwIfAborted = (signal?: AbortSignal): void => { + if (signal?.aborted) { + throw new DOMException('PSD export was aborted.', 'AbortError'); + } +}; + +/** + * Bakes one planned layer's pixels into a world-AABB-sized surface: draws the + * layer's cache through its transform (offset into the AABB), then bakes any + * non-destructive adjustments in place. Opacity/blend are NOT baked — they ride + * on the PSD layer. Returns the surface + its straight-alpha `ImageData`. + */ +const bakeLayer = async ( + planLayer: PsdPlanLayer, + deps: ExecutePsdExportDeps, + read: (surface: RasterSurface, rect: Rect) => ImageData, + write: (surface: RasterSurface, imageData: ImageData, x: number, y: number) => void +): Promise<{ surface: RasterSurface; imageData: ImageData }> => { + throwIfAborted(deps.signal); + const { worldRect } = planLayer; + const width = worldRect.width; + const height = worldRect.height; + const surface = deps.backend.createSurface(width, height); + const ctx = surface.ctx; + setTransform(ctx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + ctx.clearRect(0, 0, width, height); + + const { rect, surface: cache } = await deps.getLayerSurface(planLayer.id); + throwIfAborted(deps.signal); + // local→world then shift into AABB-local (translation only affects e/f). + const local = layerMatrix(planLayer.transform); + setTransform(ctx, { ...local, e: local.e - worldRect.x, f: local.f - worldRect.y }); + // The cache holds pixels for `rect` in layer-local space; draw at that origin. + if (rect.width > 0 && rect.height > 0) { + ctx.drawImage(cache.canvas, rect.x, rect.y); + } + + const fullRect: Rect = { height, width, x: 0, y: 0 }; + const imageData = read(surface, fullRect); + if (planLayer.adjustments) { + applyAdjustments(imageData, planLayer.adjustments); + // Write the adjusted pixels back so the flattened composite (below) reuses + // this surface directly. + write(surface, imageData, 0, 0); + } + return { imageData, surface }; +}; + +/** + * Executes a PSD export plan: bakes each layer, flattens the enabled layers into + * a merged composite (so Photoshop/Bridge show a correct preview — ag-psd does + * NOT regenerate the composite), assembles the {@link Psd}, serializes via the + * lazily-imported `ag-psd`, and triggers a download. No-op for a non-`ok` plan. + */ +export const executePsdExport = async ( + plan: PsdExportPlan, + fileName: string, + deps: ExecutePsdExportDeps +): Promise => { + if (plan.status !== 'ok') { + return; + } + const read = deps.readImageData ?? defaultReadImageData; + const write = deps.writeImageData ?? defaultWriteImageData; + const writePsdFn = deps.writePsd ?? defaultWritePsd; + const download = deps.download ?? defaultDownload; + + const children: AgPsdLayer[] = []; + const baked: { planLayer: PsdPlanLayer; surface: RasterSurface }[] = []; + + for (const planLayer of plan.layers) { + throwIfAborted(deps.signal); + const { imageData, surface } = await bakeLayer(planLayer, deps, read, write); + baked.push({ planLayer, surface }); + children.push({ + blendMode: planLayer.blendMode, + bottom: planLayer.bottom, + hidden: planLayer.hidden, + imageData, + left: planLayer.left, + name: planLayer.name, + opacity: planLayer.opacity, + right: planLayer.right, + top: planLayer.top, + }); + } + + // Flatten the enabled layers (bottom-to-top = plan order) into the merged + // composite the PSD carries as its full-document preview. + throwIfAborted(deps.signal); + const composite = deps.backend.createSurface(plan.width, plan.height); + const cctx = composite.ctx; + setTransform(cctx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + cctx.clearRect(0, 0, plan.width, plan.height); + for (const { planLayer, surface } of baked) { + if (planLayer.hidden) { + continue; + } + cctx.globalAlpha = planLayer.opacity; + cctx.globalCompositeOperation = planLayer.compositeBlend; + cctx.drawImage(surface.canvas, planLayer.left, planLayer.top); + } + cctx.globalAlpha = 1; + cctx.globalCompositeOperation = 'source-over'; + + const psd: Psd = { + children, + height: plan.height, + imageData: read(composite, { height: plan.height, width: plan.width, x: 0, y: 0 }), + width: plan.width, + }; + + throwIfAborted(deps.signal); + const bytes = await writePsdFn(psd); + throwIfAborted(deps.signal); + download(bytes, fileName); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/exportRasterComposite.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/exportRasterComposite.test.ts new file mode 100644 index 00000000000..8a5c55a2f1a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/exportRasterComposite.test.ts @@ -0,0 +1,191 @@ +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasImageRef, CanvasLayerContract } from '@workbench/types'; + +import { RasterMemoryBudgetController } from '@workbench/canvas-engine/controllers/rasterMemoryBudgetController'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ExportRasterCompositeDeps, RasterCompositeExportSnapshot } from './exportRasterComposite'; + +import { exportRasterComposite } from './exportRasterComposite'; + +const imageRef = (imageName: string, width = 64, height = 32): CanvasImageRef => ({ height, imageName, width }); + +const createDeferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +const rasterLayer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: imageRef(id), type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: -4, y: 8 }, + type: 'raster', +}); + +const controlLayer = (id: string): CanvasLayerContract => ({ + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: imageRef(id), type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: false, +}); + +const makeDoc = (layers: CanvasLayerContract[]): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 128, width: 128, x: 0, y: 0 }, + height: 128, + layers, + selectedLayerId: null, + version: 2, + width: 128, +}); + +const makeDeps = (document: CanvasDocumentContractV2) => { + const stub = createTestStubRasterBackend(); + const snapshot: RasterCompositeExportSnapshot = { + contentEpoch: 1, + document, + documentGeneration: 1, + lifecycleGeneration: 1, + }; + const encodeSurface = vi.fn((surface: RasterSurface, type?: string) => stub.encodeSurface(surface, type)); + const deps: ExportRasterCompositeDeps = { + backend: { + createSurface: (width, height) => stub.createSurface(width, height), + encodeSurface, + }, + captureSnapshot: vi.fn(() => snapshot), + getLayerSurface: vi.fn(() => + Promise.resolve({ + rect: { height: 32, width: 64, x: 0, y: 0 }, + surface: stub.createSurface(64, 32), + }) + ), + isSnapshotCurrent: vi.fn(() => true), + }; + return { deps, encodeSurface }; +}; + +describe('exportRasterComposite', () => { + it('exports tight content as a PNG without uploading it', async () => { + const { deps, encodeSurface } = makeDeps(makeDoc([rasterLayer('raster')])); + + const result = await exportRasterComposite({ bounds: 'content' }, deps); + + expect(result).toMatchObject({ status: 'ok', rect: { x: -4, y: 8, width: 64, height: 32 } }); + expect(encodeSurface).toHaveBeenCalledOnce(); + expect(encodeSurface).toHaveBeenCalledWith(expect.anything(), 'image/png'); + expect(deps).not.toHaveProperty('uploadImage'); + }); + + it('exports the exact requested bbox', async () => { + const { deps } = makeDeps(makeDoc([rasterLayer('raster')])); + const bbox: Rect = { height: 48, width: 96, x: 12, y: -6 }; + + await expect(exportRasterComposite({ bounds: 'rect', rect: bbox }, deps)).resolves.toMatchObject({ + status: 'ok', + rect: bbox, + }); + }); + + it('returns empty when the document has no enabled raster content', async () => { + const { deps: noContentDeps } = makeDeps(makeDoc([controlLayer('control')])); + + await expect(exportRasterComposite({ bounds: 'content' }, noContentDeps)).resolves.toEqual({ status: 'empty' }); + }); + + it('returns empty for a zero-area requested rect', async () => { + const { deps } = makeDeps(makeDoc([rasterLayer('raster')])); + + await expect( + exportRasterComposite({ bounds: 'rect', rect: { x: 0, y: 0, width: 0, height: 32 } }, deps) + ).resolves.toEqual({ status: 'empty' }); + }); + + it('returns over-budget before allocating a background composite', async () => { + const { deps, encodeSurface } = makeDeps(makeDoc([rasterLayer('raster')])); + deps.reserve = vi.fn(() => ({ availableBytes: 1_000, requestedBytes: 8_192, status: 'over-budget' as const })); + + await expect(exportRasterComposite({ bounds: 'content' }, deps)).resolves.toEqual({ status: 'over-budget' }); + expect(deps.getLayerSurface).not.toHaveBeenCalled(); + expect(encodeSurface).not.toHaveBeenCalled(); + }); + + it('releases the reservation and cache pins after export', async () => { + const { deps } = makeDeps(makeDoc([rasterLayer('raster')])); + const releaseReservation = vi.fn(); + const releasePins = vi.fn(); + deps.reserve = vi.fn(() => ({ lease: { release: releaseReservation }, status: 'ok' as const })); + deps.pin = vi.fn(() => ({ release: releasePins })); + + await expect(exportRasterComposite({ bounds: 'content' }, deps)).resolves.toMatchObject({ status: 'ok' }); + expect(deps.pin).toHaveBeenCalledWith(['raster']); + expect(releasePins).toHaveBeenCalledOnce(); + expect(releaseReservation).toHaveBeenCalledOnce(); + }); + + it('keeps its reservation and cache pins while rendering across generation release', async () => { + const { deps } = makeDeps(makeDoc([rasterLayer('raster')])); + const memory = new RasterMemoryBudgetController({ budgetBytes: 100_000 }); + const layerSurface = createDeferred<{ rect: Rect; surface: RasterSurface }>(); + deps.getLayerSurface = vi.fn(() => layerSurface.promise); + deps.reserve = (bytes) => memory.reserveOperation(bytes, { purpose: 'background-snapshot' }); + deps.pin = (layerIds) => { + const leases = layerIds.map((layerId) => memory.pinOperation(layerId)); + return { release: () => leases.forEach((lease) => lease.release()) }; + }; + + const exported = exportRasterComposite({ bounds: 'content' }, deps); + await vi.waitFor(() => expect(memory.snapshot().reservedBytes).toBeGreaterThan(0)); + memory.releaseGeneration(1); + + expect(memory.snapshot().reservedBytes).toBeGreaterThan(0); + expect(memory.isPinned('raster')).toBe(true); + + const backend = createTestStubRasterBackend(); + layerSurface.resolve({ + rect: { height: 32, width: 64, x: 0, y: 0 }, + surface: backend.createSurface(64, 32), + }); + await expect(exported).resolves.toMatchObject({ status: 'ok' }); + expect(memory.snapshot().reservedBytes).toBe(0); + expect(memory.isPinned('raster')).toBe(false); + }); + + it('reserves both the temporary surface and ImageData for an adjusted layer', async () => { + const layer = rasterLayer('raster'); + if (layer.type !== 'raster') { + throw new Error('Expected raster layer'); + } + const { deps } = makeDeps(makeDoc([{ ...layer, adjustments: { brightness: 0.1, contrast: 0, saturation: 0 } }])); + const reserve = vi.fn(() => ({ lease: { release: vi.fn() }, status: 'ok' as const })); + deps.reserve = reserve; + + await expect(exportRasterComposite({ bounds: 'content' }, deps)).resolves.toMatchObject({ status: 'ok' }); + + expect(reserve).toHaveBeenCalledWith(24_576); + }); + + it('discards an encoded blob if the document changes during export', async () => { + const { deps } = makeDeps(makeDoc([rasterLayer('raster')])); + vi.mocked(deps.isSnapshotCurrent).mockReturnValueOnce(true).mockReturnValue(false); + + await expect(exportRasterComposite({ bounds: 'content' }, deps)).resolves.toEqual({ status: 'stale' }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/exportRasterComposite.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/exportRasterComposite.ts new file mode 100644 index 00000000000..ac78c2041d2 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/exportRasterComposite.ts @@ -0,0 +1,101 @@ +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { isEmpty } from '@workbench/canvas-engine/math/rect'; +import { + getBaseRasterContentBounds, + planBaseRasterComposite, + renderRasterComposite, + type RenderRasterCompositeDeps, +} from '@workbench/canvas-engine/render/rasterComposite'; + +export type RasterCompositeExportRequest = { bounds: 'content' } | { bounds: 'rect'; rect: Rect }; + +export type RasterCompositeExportResult = + | { status: 'ok'; blob: Blob; rect: Rect } + | { status: 'empty' | 'stale' | 'not-ready' | 'over-budget' }; + +export interface RasterCompositeExportSnapshot { + contentEpoch: number; + document: CanvasDocumentContractV2 | null; + documentGeneration: number; + lifecycleGeneration: number; +} + +export class RasterCompositeOverBudgetError extends Error { + constructor() { + super('Raster composite allocation is over budget.'); + this.name = 'RasterCompositeOverBudgetError'; + } +} + +export interface ExportRasterCompositeDeps extends RenderRasterCompositeDeps { + backend: RenderRasterCompositeDeps['backend'] & { + encodeSurface(surface: RasterSurface, type?: string): Promise; + }; + captureSnapshot(): RasterCompositeExportSnapshot; + isSnapshotCurrent(snapshot: RasterCompositeExportSnapshot): boolean; + reserve?( + bytes: number + ): + | { status: 'ok'; lease: { release(): void } } + | { status: 'over-budget'; requestedBytes: number; availableBytes: number }; + pin?(layerIds: readonly string[]): { release(): void }; +} + +export const exportRasterComposite = async ( + request: RasterCompositeExportRequest, + deps: ExportRasterCompositeDeps +): Promise => { + const snapshot = deps.captureSnapshot(); + const document = snapshot.document; + if (!document) { + return { status: 'not-ready' }; + } + + const rect = request.bounds === 'content' ? getBaseRasterContentBounds(document) : request.rect; + if (!rect || isEmpty(rect)) { + return { status: 'empty' }; + } + + const entry = planBaseRasterComposite(document, rect); + if (entry.layers.length === 0) { + return { status: 'empty' }; + } + + // Each adjusted layer needs both a temporary surface and a same-sized + // ImageData buffer in addition to the final composite surface. + const surfaceCount = 1 + entry.layers.filter((layer) => layer.adjustments !== undefined).length * 2; + const reservation = deps.reserve?.(rect.width * rect.height * 4 * surfaceCount); + if (reservation?.status === 'over-budget') { + return { status: 'over-budget' }; + } + const pinLease = deps.pin?.(entry.layers.map((layer) => layer.id)); + + try { + let surface: RasterSurface; + try { + surface = await renderRasterComposite(entry, deps); + } catch (error) { + if (error instanceof RasterCompositeOverBudgetError) { + return { status: 'over-budget' }; + } + if (!deps.isSnapshotCurrent(snapshot)) { + return { status: 'stale' }; + } + throw error; + } + if (!deps.isSnapshotCurrent(snapshot)) { + return { status: 'stale' }; + } + + const blob = await deps.backend.encodeSurface(surface, 'image/png'); + return deps.isSnapshotCurrent(snapshot) ? { status: 'ok', blob, rect } : { status: 'stale' }; + } finally { + pinLease?.release(); + if (reservation?.status === 'ok') { + reservation.lease.release(); + } + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/filterError.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/filterError.ts new file mode 100644 index 00000000000..e641a9ac1fd --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/filterError.ts @@ -0,0 +1,18 @@ +import type { Rect } from '@workbench/canvas-engine/types'; + +const filterName = (filterType: string): string => + filterType + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + +export class LayerFilterOutputDimensionError extends Error { + readonly code = 'output-dimension' as const; + + constructor(filterType: string, output: { width: number; height: number }, source: Rect) { + super( + `${filterName(filterType)} output dimensions ${String(output.width)}x${String(output.height)} do not match source dimensions ${source.width}x${source.height}.` + ); + this.name = 'LayerFilterOutputDimensionError'; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/freehand.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/freehand.test.ts new file mode 100644 index 00000000000..c9eff1ac481 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/freehand.test.ts @@ -0,0 +1,85 @@ +import type { StrokeSamplePoint } from '@workbench/canvas-engine/freehand'; +import type { Vec2 } from '@workbench/canvas-engine/types'; + +import { polygonBounds, polygonToSvgPath, strokeOutlinePolygon, strokeToPath } from '@workbench/canvas-engine/freehand'; +import { describe, expect, it } from 'vitest'; + +/** Signed polygon area (shoelace); sign indicates winding, magnitude the area. */ +const signedArea = (polygon: readonly Vec2[]): number => { + let sum = 0; + for (let i = 0; i < polygon.length; i++) { + const a = polygon[i]!; + const b = polygon[(i + 1) % polygon.length]!; + sum += a.x * b.y - b.x * a.y; + } + return sum / 2; +}; + +const horizontalLine = (pressure: number): StrokeSamplePoint[] => + Array.from({ length: 9 }, (_, i) => ({ pressure, x: 10 + i * 10, y: 50 })); + +describe('strokeOutlinePolygon', () => { + it('returns an empty polygon for no points', () => { + expect(strokeOutlinePolygon([], { size: 20 })).toEqual([]); + }); + + it('produces a closed, non-degenerate outline around a straight line', () => { + const polygon = strokeOutlinePolygon(horizontalLine(0.5), { size: 20, thinning: 0 }); + expect(polygon.length).toBeGreaterThan(2); + // A real 2D band, not a collinear degenerate: non-zero winding area. + expect(Math.abs(signedArea(polygon))).toBeGreaterThan(0); + }); + + it('brackets the input points with a width near the base size (no thinning)', () => { + const size = 20; + const bounds = polygonBounds(strokeOutlinePolygon(horizontalLine(0.5), { size, thinning: 0 })); + // The band spans the drawn length (~80px) plus the round caps. + expect(bounds.width).toBeGreaterThan(80); + // With thinning disabled the perpendicular extent tracks the base diameter. + expect(bounds.height).toBeGreaterThan(size * 0.5); + expect(bounds.height).toBeLessThan(size * 2); + // Centered on the y=50 line the points sit on. + expect(bounds.y).toBeLessThan(50); + expect(bounds.y + bounds.height).toBeGreaterThan(50); + }); + + it('widens the stroke as pressure increases when thinning is enabled', () => { + const opts = { size: 40, thinning: 0.8 } as const; + const light = polygonBounds(strokeOutlinePolygon(horizontalLine(0.1), opts)); + const heavy = polygonBounds(strokeOutlinePolygon(horizontalLine(0.95), opts)); + expect(heavy.height).toBeGreaterThan(light.height); + }); +}); + +describe('polygonToSvgPath', () => { + it('serializes a polygon to a closed move/line path', () => { + const path = polygonToSvgPath([ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 5 }, + ]); + expect(path).toBe('M 0 0 L 10 0 L 10 5 Z'); + }); + + it('returns an empty string for an empty polygon', () => { + expect(polygonToSvgPath([])).toBe(''); + }); +}); + +describe('strokeToPath', () => { + it('builds the path via the injected factory and returns the polygon bounds', () => { + const seen: string[] = []; + const marker = { __brand: 'path' } as unknown as Path2D; + const createPath2D = (d?: string): Path2D => { + seen.push(d ?? ''); + return marker; + }; + const result = strokeToPath(horizontalLine(0.5), { size: 20, thinning: 0 }, createPath2D); + + expect(result.path).toBe(marker); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatch(/^M /); + expect(result.polygon.length).toBeGreaterThan(2); + expect(result.bounds).toEqual(polygonBounds(result.polygon)); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/freehand.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/freehand.ts new file mode 100644 index 00000000000..83e57cd3d4f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/freehand.ts @@ -0,0 +1,122 @@ +/** + * A thin wrapper over `perfect-freehand`, turning a pressure-sampled point path + * into the filled outline polygon of a variable-width stroke. + * + * The polygon math ({@link strokeOutlinePolygon}) is pure and DOM-free, so it is + * fully unit-testable in node. `Path2D` does not exist in node, so building the + * fillable path is split out into {@link strokeToPath}, which takes an injected + * `createPath2D` factory (the engine passes `(d) => new Path2D(d)`; tests pass a + * stub). This keeps the interesting geometry testable without a DOM. + * + * Zero React, zero import-time side effects. + */ + +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; + +import { getStroke } from 'perfect-freehand'; + +/** A single pressure-sampled input point for a stroke. */ +export interface StrokeSamplePoint { + x: number; + y: number; + /** Pressure in [0, 1]. */ + pressure: number; +} + +/** Tuning for the freehand outline. `size` is the base stroke diameter in document units. */ +export interface FreehandOptions { + /** Base stroke diameter (document units). */ + size: number; + /** Effect of pressure on width, [0, 1]. 0 disables pressure sensitivity. */ + thinning?: number; + /** Edge softening, [0, 1]. */ + smoothing?: number; + /** Input smoothing / lag, [0, 1]. */ + streamline?: number; + /** Whether the stroke is complete (adds the end cap). */ + last?: boolean; +} + +/** Factory for a `Path2D`; injected so the polygon math stays node-testable. */ +export type CreatePath2D = (d?: string) => Path2D; + +const DEFAULT_SMOOTHING = 0.5; +const DEFAULT_STREAMLINE = 0.5; +const DEFAULT_THINNING = 0.5; + +/** + * Computes the filled outline polygon (a closed ring of document-space points) + * of a variable-width stroke through `points`. Pure and DOM-free. + */ +export const strokeOutlinePolygon = (points: readonly StrokeSamplePoint[], opts: FreehandOptions): Vec2[] => { + if (points.length === 0) { + return []; + } + const outline = getStroke( + points.map((p) => [p.x, p.y, p.pressure]), + { + last: opts.last ?? false, + simulatePressure: false, + size: opts.size, + smoothing: opts.smoothing ?? DEFAULT_SMOOTHING, + streamline: opts.streamline ?? DEFAULT_STREAMLINE, + thinning: opts.thinning ?? DEFAULT_THINNING, + } + ); + return outline.map(([x, y]) => ({ x, y })); +}; + +/** Serializes an outline polygon to an SVG path string (`M … L … Z`). */ +export const polygonToSvgPath = (polygon: readonly Vec2[]): string => { + if (polygon.length === 0) { + return ''; + } + const [first, ...rest] = polygon; + let d = `M ${first.x} ${first.y}`; + for (const p of rest) { + d += ` L ${p.x} ${p.y}`; + } + return `${d} Z`; +}; + +/** Axis-aligned bounds of an outline polygon; an empty polygon yields a zero-size rect. */ +export const polygonBounds = (polygon: readonly Vec2[]): Rect => { + if (polygon.length === 0) { + return { height: 0, width: 0, x: 0, y: 0 }; + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const p of polygon) { + if (p.x < minX) { + minX = p.x; + } + if (p.y < minY) { + minY = p.y; + } + if (p.x > maxX) { + maxX = p.x; + } + if (p.y > maxY) { + maxY = p.y; + } + } + return { height: maxY - minY, width: maxX - minX, x: minX, y: minY }; +}; + +/** + * Builds a fillable `Path2D` for the stroke through `points`, using the injected + * `createPath2D` factory (so callers in node can stub it). Returns the built + * path and the polygon's document-space {@link Rect} bounds so the caller can + * derive the dirty region without recomputing the outline. + */ +export const strokeToPath = ( + points: readonly StrokeSamplePoint[], + opts: FreehandOptions, + createPath2D: CreatePath2D +): { path: Path2D; polygon: Vec2[]; bounds: Rect } => { + const polygon = strokeOutlinePolygon(points, opts); + const path = createPath2D(polygonToSvgPath(polygon)); + return { bounds: polygonBounds(polygon), path, polygon }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/history/documentPatch.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/documentPatch.test.ts new file mode 100644 index 00000000000..b112710d1ff --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/documentPatch.test.ts @@ -0,0 +1,30 @@ +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createDocumentPatchEntry, DOCUMENT_PATCH_DEFAULT_BYTES } from './documentPatch'; + +const forward: CanvasProjectMutation = { direction: 1, type: 'cycleStagedImage' }; +const inverse: CanvasProjectMutation = { direction: -1, type: 'cycleStagedImage' }; + +describe('createDocumentPatchEntry', () => { + it('dispatches inverse on undo and forward on redo', () => { + const dispatch = vi.fn(); + const entry = createDocumentPatchEntry({ dispatch, forward, inverse, label: 'Cycle' }); + + entry.undo(); + expect(dispatch).toHaveBeenNthCalledWith(1, inverse); + entry.redo(); + expect(dispatch).toHaveBeenNthCalledWith(2, forward); + }); + + it('defaults bytes to a small nominal cost, overridable', () => { + const dispatch = vi.fn(); + const entry = createDocumentPatchEntry({ dispatch, forward, inverse, label: 'Cycle' }); + expect(entry.bytes).toBe(DOCUMENT_PATCH_DEFAULT_BYTES); + + const heavier = createDocumentPatchEntry({ bytes: 4096, dispatch, forward, inverse, label: 'Cycle' }); + expect(heavier.bytes).toBe(4096); + expect(heavier.label).toBe('Cycle'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/history/documentPatch.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/documentPatch.ts new file mode 100644 index 00000000000..c1c296b30a6 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/documentPatch.ts @@ -0,0 +1,49 @@ +/** + * A structural history entry: a pair of reducer actions (forward + inverse) + * dispatched to undo/redo a document-shape change. + * + * Unlike a pixel {@link createImagePatchEntry | image patch}, a document patch + * carries no bitmaps — just two {@link CanvasProjectMutation}s. Undo dispatches the + * `inverse`, redo dispatches the `forward`. It exists so structural canvas edits + * (add/remove/reorder layer, rename, …) can live on the same engine-owned undo + * stack as paint edits. Phase 3's layers-panel operations lean on this; P2.1 uses + * it lightly (if at all) for composing an auto-created layer into a stroke's undo. + * + * Byte cost is tiny (actions are small plain objects); the default keeps it off + * the byte budget's radar while still counting as one of the entry-count slots. + * + * Zero React, zero DOM, zero import-time side effects. + */ + +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; + +import type { HistoryEntry } from './history'; + +/** Nominal byte cost for a structural entry (small; actions are plain objects). */ +export const DOCUMENT_PATCH_DEFAULT_BYTES = 256; + +/** Options for {@link createDocumentPatchEntry}. */ +export interface CreateDocumentPatchEntryOptions { + label: string; + /** The action that performs the change (dispatched on redo). */ + forward: CanvasProjectMutation; + /** The action that reverses the change (dispatched on undo). */ + inverse: CanvasProjectMutation; + /** The reducer bridge. */ + dispatch(action: CanvasProjectMutation): void; + /** Approximate retained size (default {@link DOCUMENT_PATCH_DEFAULT_BYTES}). */ + bytes?: number; +} + +/** Creates a reversible structural entry that dispatches inverse on undo, forward on redo. */ +export const createDocumentPatchEntry = (opts: CreateDocumentPatchEntryOptions): HistoryEntry => { + const { dispatch, forward, inverse, label } = opts; + const bytes = opts.bytes ?? DOCUMENT_PATCH_DEFAULT_BYTES; + + return { + bytes, + label, + redo: () => dispatch(forward), + undo: () => dispatch(inverse), + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/history/history.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/history.test.ts new file mode 100644 index 00000000000..b2d27d70551 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/history.test.ts @@ -0,0 +1,502 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { HistoryEntry } from './history'; + +import { createHistory, HISTORY_BYTE_BUDGET, HISTORY_MAX_ENTRIES } from './history'; + +/** A tiny entry that records undo/redo calls into a shared log. */ +const makeEntry = (label: string, log: string[], bytes = 0): HistoryEntry => ({ + bytes, + label, + redo: () => log.push(`redo:${label}`), + undo: () => log.push(`undo:${label}`), +}); + +describe('createHistory: push / undo / redo ordering', () => { + it('undoes and redoes entries in LIFO order', () => { + const log: string[] = []; + const history = createHistory(); + history.push(makeEntry('a', log)); + history.push(makeEntry('b', log)); + history.push(makeEntry('c', log)); + + history.undo(); + history.undo(); + expect(log).toEqual(['undo:c', 'undo:b']); + + history.redo(); + expect(log).toEqual(['undo:c', 'undo:b', 'redo:b']); + + history.undo(); + history.undo(); + expect(log).toEqual(['undo:c', 'undo:b', 'redo:b', 'undo:b', 'undo:a']); + }); + + it('undo / redo are no-ops on empty stacks', () => { + const log: string[] = []; + const history = createHistory(); + history.undo(); + history.redo(); + expect(log).toEqual([]); + expect(history.canUndo()).toBe(false); + expect(history.canRedo()).toBe(false); + }); +}); + +describe('createHistory: redo cleared on push', () => { + it('drops the redo stack when a new entry is pushed', () => { + const log: string[] = []; + const history = createHistory(); + history.push(makeEntry('a', log)); + history.push(makeEntry('b', log)); + history.undo(); // b -> redo + expect(history.canRedo()).toBe(true); + + history.push(makeEntry('c', log)); + expect(history.canRedo()).toBe(false); + + // Redoing does nothing: the redo stack was cleared by the push. + history.redo(); + expect(log).toEqual(['undo:b']); + }); +}); + +describe('createHistory: entry-count eviction', () => { + it('evicts the oldest entry beyond the 64-entry budget', () => { + const log: string[] = []; + const history = createHistory(); + // Push one past the default cap. + for (let i = 0; i < HISTORY_MAX_ENTRIES + 1; i += 1) { + history.push(makeEntry(`e${i}`, log)); + } + // Undo every retained entry: there should be exactly the cap, and the very + // first entry (e0) should have been evicted (never undone). + let undos = 0; + while (history.canUndo()) { + history.undo(); + undos += 1; + } + expect(undos).toBe(HISTORY_MAX_ENTRIES); + expect(log).not.toContain('undo:e0'); + expect(log).toContain('undo:e1'); + expect(log[log.length - 1]).toBe('undo:e1'); + }); + + it('honors a custom maxEntries', () => { + const log: string[] = []; + const history = createHistory({ maxEntries: 2 }); + history.push(makeEntry('a', log)); + history.push(makeEntry('b', log)); + history.push(makeEntry('c', log)); // evicts 'a' + let undos = 0; + while (history.canUndo()) { + history.undo(); + undos += 1; + } + expect(undos).toBe(2); + expect(log).toEqual(['undo:c', 'undo:b']); + }); +}); + +describe('createHistory: byte-budget eviction', () => { + it('evicts oldest entries until under the byte budget', () => { + const log: string[] = []; + // Budget fits two 10-byte entries but not three. + const history = createHistory({ byteBudget: 25 }); + history.push(makeEntry('a', log, 10)); + history.push(makeEntry('b', log, 10)); + expect(history.canUndo()).toBe(true); + history.push(makeEntry('c', log, 10)); // total 30 > 25 -> evict 'a' + + let undos = 0; + while (history.canUndo()) { + history.undo(); + undos += 1; + } + expect(undos).toBe(2); + expect(log).toEqual(['undo:c', 'undo:b']); + }); + + it('exports the 256 MB default byte budget', () => { + expect(HISTORY_BYTE_BUDGET).toBe(256 * 1024 * 1024); + }); +}); + +describe('createHistory: change listener', () => { + it('fires on push / undo / redo / clear', () => { + const log: string[] = []; + const history = createHistory(); + const listener = vi.fn(); + const unsubscribe = history.subscribe(listener); + + history.push(makeEntry('a', log)); + expect(listener).toHaveBeenCalledTimes(1); + history.undo(); + expect(listener).toHaveBeenCalledTimes(2); + history.redo(); + expect(listener).toHaveBeenCalledTimes(3); + history.clear(); + expect(listener).toHaveBeenCalledTimes(4); + + // Clear again with empty stacks: no change, no notification. + history.clear(); + expect(listener).toHaveBeenCalledTimes(4); + + unsubscribe(); + history.push(makeEntry('b', log)); + expect(listener).toHaveBeenCalledTimes(4); + }); + + it('reflects canUndo / canRedo transitions', () => { + const log: string[] = []; + const history = createHistory(); + expect(history.canUndo()).toBe(false); + history.push(makeEntry('a', log)); + expect(history.canUndo()).toBe(true); + expect(history.canRedo()).toBe(false); + history.undo(); + expect(history.canUndo()).toBe(false); + expect(history.canRedo()).toBe(true); + }); +}); + +describe('createHistory: amendLast', () => { + it('replaces the most recent entry in place and adjusts the byte total', () => { + const log: string[] = []; + const history = createHistory({ byteBudget: 25 }); + history.push(makeEntry('a', log, 10)); + history.push(makeEntry('b', log, 10)); // burst start + + // Coalesce: replace 'b' with 'b2' (still one burst entry). + history.amendLast(makeEntry('b2', log, 10)); + + let undos = 0; + while (history.canUndo()) { + history.undo(); + undos += 1; + } + // Two entries retained (a + b2), not three: amend did not grow the stack. + expect(undos).toBe(2); + expect(log).toEqual(['undo:b2', 'undo:a']); + }); + + it('pushes when the undo stack is empty', () => { + const log: string[] = []; + const history = createHistory(); + history.amendLast(makeEntry('a', log)); + expect(history.canUndo()).toBe(true); + history.undo(); + expect(log).toEqual(['undo:a']); + }); + + it('clears the redo stack like push', () => { + const log: string[] = []; + const history = createHistory(); + history.push(makeEntry('a', log)); + history.push(makeEntry('b', log)); + history.undo(); // b -> redo + expect(history.canRedo()).toBe(true); + history.amendLast(makeEntry('a2', log)); + expect(history.canRedo()).toBe(false); + }); + + it('is a no-op while applying', () => { + const log: string[] = []; + const history = createHistory(); + const reentrant: HistoryEntry = { + bytes: 0, + label: 'reentrant', + redo: () => {}, + undo: () => history.amendLast(makeEntry('sneaky', log)), + }; + history.push(reentrant); + history.undo(); + // The amend during undo was dropped; only the reentrant entry is reachable. + history.redo(); + let undos = 0; + while (history.canUndo()) { + history.undo(); + undos += 1; + } + expect(undos).toBe(1); + }); +}); + +describe('createHistory: re-entrancy guard', () => { + it('reports isApplying during undo/redo and drops entries pushed while applying', () => { + const log: string[] = []; + const history = createHistory(); + const observed: boolean[] = []; + + const reentrant: HistoryEntry = { + bytes: 0, + label: 'reentrant', + redo: () => { + observed.push(history.isApplying()); + // A replay must not be able to record a new entry. + history.push(makeEntry('sneaky', log)); + }, + undo: () => { + observed.push(history.isApplying()); + history.push(makeEntry('sneaky', log)); + }, + }; + + expect(history.isApplying()).toBe(false); + history.push(reentrant); + history.undo(); + expect(observed).toEqual([true]); + expect(history.isApplying()).toBe(false); + // The sneaky push during undo was dropped: redo stack still holds only the + // reentrant entry, and no 'sneaky' entry is reachable. + expect(history.canRedo()).toBe(true); + + history.redo(); + expect(observed).toEqual([true, true]); + // Still exactly one undoable entry (the reentrant one); the sneaky pushes + // never landed. + let undos = 0; + while (history.canUndo()) { + history.undo(); + undos += 1; + } + expect(undos).toBe(1); + }); +}); + +describe('createHistory: failure-atomic replay', () => { + it('keeps a failed undo on the undo stack and allows an exact retry', () => { + const history = createHistory(); + const listener = vi.fn(); + let shouldFail = true; + const undo = vi.fn(() => { + if (shouldFail) { + throw new Error('undo preparation failed'); + } + }); + const redo = vi.fn(); + history.subscribe(listener); + history.push({ bytes: 17, label: 'fallible', redo, replayFailureAtomic: true, undo }); + listener.mockClear(); + + expect(() => history.undo()).toThrow('undo preparation failed'); + expect(history.canUndo()).toBe(true); + expect(history.canRedo()).toBe(false); + expect(history.isApplying()).toBe(false); + expect(listener).not.toHaveBeenCalled(); + + shouldFail = false; + history.undo(); + expect(undo).toHaveBeenCalledTimes(2); + expect(history.canUndo()).toBe(false); + expect(history.canRedo()).toBe(true); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('keeps a failed redo on the redo stack and allows an exact retry', () => { + const history = createHistory(); + const listener = vi.fn(); + let shouldFail = true; + const undo = vi.fn(); + const redo = vi.fn(() => { + if (shouldFail) { + throw new Error('redo preparation failed'); + } + }); + history.subscribe(listener); + history.push({ bytes: 23, label: 'fallible', redo, replayFailureAtomic: true, undo }); + history.undo(); + listener.mockClear(); + + expect(() => history.redo()).toThrow('redo preparation failed'); + expect(history.canUndo()).toBe(false); + expect(history.canRedo()).toBe(true); + expect(history.isApplying()).toBe(false); + expect(listener).not.toHaveBeenCalled(); + + shouldFail = false; + history.redo(); + expect(redo).toHaveBeenCalledTimes(2); + expect(history.canUndo()).toBe(true); + expect(history.canRedo()).toBe(false); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('preserves byte-budget accounting after a failed replay', () => { + const log: string[] = []; + const history = createHistory({ byteBudget: 20 }); + let shouldFail = true; + history.push(makeEntry('a', log, 10)); + history.push({ + bytes: 10, + label: 'b', + redo: () => log.push('redo:b'), + replayFailureAtomic: true, + undo: () => { + if (shouldFail) { + throw new Error('failed'); + } + log.push('undo:b'); + }, + }); + + expect(() => history.undo()).toThrow('failed'); + shouldFail = false; + history.push(makeEntry('c', log, 10)); + + // Correct restored accounting is 30 bytes, so the oldest entry is evicted. + history.undo(); + history.undo(); + expect(log).toEqual(['undo:c', 'undo:b']); + expect(history.canUndo()).toBe(false); + }); + + it.each([false, true])( + 'does not resurrect an entry when undo clears history (failure-atomic: %s)', + (replayFailureAtomic) => { + const history = createHistory(); + const listener = vi.fn(); + history.subscribe(listener); + history.push({ + bytes: 10, + label: 'clear-on-undo', + redo: () => {}, + replayFailureAtomic, + undo: () => history.clear(), + }); + listener.mockClear(); + + history.undo(); + + expect(history.canUndo()).toBe(false); + expect(history.canRedo()).toBe(false); + expect(listener).toHaveBeenCalledOnce(); + } + ); + + it.each([false, true])( + 'does not resurrect an entry when redo clears history (failure-atomic: %s)', + (replayFailureAtomic) => { + const history = createHistory(); + const listener = vi.fn(); + let clearOnRedo = false; + history.subscribe(listener); + history.push({ + bytes: 10, + label: 'clear-on-redo', + redo: () => { + if (clearOnRedo) { + history.clear(); + } + }, + replayFailureAtomic, + undo: () => {}, + }); + history.undo(); + clearOnRedo = true; + listener.mockClear(); + + history.redo(); + + expect(history.canUndo()).toBe(false); + expect(history.canRedo()).toBe(false); + expect(listener).toHaveBeenCalledOnce(); + } + ); + + it('moves a legacy undo before replay, notifies, and rethrows a post-application failure', () => { + const history = createHistory(); + const listener = vi.fn(); + const applied: string[] = []; + history.subscribe(listener); + history.push({ + bytes: 10, + label: 'legacy-fallible-undo', + redo: () => applied.push('redo'), + undo: () => { + applied.push('undo'); + throw new Error('undo observer failed'); + }, + }); + listener.mockClear(); + + expect(() => history.undo()).toThrow('undo observer failed'); + + expect(history.canUndo()).toBe(false); + expect(history.canRedo()).toBe(true); + expect(history.isApplying()).toBe(false); + expect(applied).toEqual(['undo']); + expect(listener).toHaveBeenCalledOnce(); + + history.redo(); + expect(applied).toEqual(['undo', 'redo']); + }); + + it('moves a legacy redo before replay, notifies, and rethrows a post-application failure', () => { + const history = createHistory(); + const listener = vi.fn(); + const applied: string[] = []; + let shouldFail = false; + history.subscribe(listener); + history.push({ + bytes: 10, + label: 'legacy-fallible-redo', + redo: () => { + applied.push('redo'); + if (shouldFail) { + throw new Error('redo observer failed'); + } + }, + undo: () => applied.push('undo'), + }); + history.undo(); + shouldFail = true; + listener.mockClear(); + + expect(() => history.redo()).toThrow('redo observer failed'); + + expect(history.canUndo()).toBe(true); + expect(history.canRedo()).toBe(false); + expect(history.isApplying()).toBe(false); + expect(applied).toEqual(['undo', 'redo']); + expect(listener).toHaveBeenCalledOnce(); + + history.undo(); + expect(applied).toEqual(['undo', 'redo', 'undo']); + }); + + it('isolates listener failures after successful stack mutations', () => { + const log: string[] = []; + const history = createHistory(); + const listener = vi.fn(); + history.subscribe(() => { + throw new Error('history listener failed'); + }); + history.subscribe(listener); + + expect(() => history.push(makeEntry('a', log))).not.toThrow(); + expect(() => history.undo()).not.toThrow(); + expect(() => history.redo()).not.toThrow(); + expect(() => history.clear()).not.toThrow(); + + expect(listener).toHaveBeenCalledTimes(4); + expect(history.canUndo()).toBe(false); + expect(history.canRedo()).toBe(false); + }); + + it('trims oldest entries by retained bytes while preserving the newest undo history', () => { + const log: string[] = []; + const history = createHistory({ byteBudget: 1_000 }); + history.push({ ...makeEntry('oldest', log), bytes: 40 }); + history.push({ ...makeEntry('middle', log), bytes: 50 }); + history.push({ ...makeEntry('newest', log), bytes: 60 }); + + history.trimToBytes(110); + + expect(history.byteSize()).toBe(110); + history.undo(); + history.undo(); + history.undo(); + expect(log).toEqual(['undo:newest', 'undo:middle']); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/history/history.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/history.ts new file mode 100644 index 00000000000..ddcef4b29ab --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/history.ts @@ -0,0 +1,334 @@ +/** + * Engine-owned canvas history: a bounded undo/redo stack of opaque entries. + * + * Project-level undo (`pushUndo` in the reducer) deliberately no longer covers + * the canvas (Phase 0) — pixels never cross the reducer boundary, so the reducer + * can't cheaply snapshot them. Instead the canvas keeps its OWN history here, + * living entirely inside the engine. Each {@link HistoryEntry} is a self-contained + * undo/redo pair (a paint {@link createImagePatchEntry | pixel patch} or a + * structural {@link createDocumentPatchEntry | document patch}) plus a byte cost + * used to bound memory. + * + * Budgets (evict oldest beyond EITHER dimension): + * - at most {@link HISTORY_MAX_ENTRIES} undo entries, and + * - at most {@link HISTORY_BYTE_BUDGET} bytes across both stacks. + * + * Re-entrancy: while an entry's `undo`/`redo` runs, {@link History.isApplying} + * is `true`. The engine's paint/apply paths check it and skip recording, and + * `push` is a hard no-op during apply — so replaying an entry can never spawn a + * new entry (which would corrupt the stacks). + * + * Zero React, zero DOM, zero import-time side effects. + */ + +/** Max number of undo entries retained before the oldest is evicted. */ +export const HISTORY_MAX_ENTRIES = 64; + +/** Max total bytes retained across the undo + redo stacks before the oldest is evicted (256 MB). */ +export const HISTORY_BYTE_BUDGET = 256 * 1024 * 1024; + +/** One reversible step. `bytes` is the (approximate) memory the entry pins, for the budget. */ +export interface HistoryEntry { + /** Human-readable label (e.g. "Brush stroke"). */ + readonly label: string; + /** Approximate retained size in bytes (e.g. before+after ImageData byteLength). */ + readonly bytes: number; + /** + * Opts into failure-atomic replay. When true, History moves this entry only + * after `undo`/`redo` returns successfully, so a preparation failure remains + * exactly retryable. The callback must leave its domain unchanged on throw. + * + * Legacy entries default to move-before-replay semantics: a throw is treated + * as post-application observer failure and the entry stays on its destination + * stack, preventing a retry from applying the same mutation twice. + */ + readonly replayFailureAtomic?: boolean; + /** Reverts the change. Must not push new history entries. */ + undo(): void; + /** Re-applies the change. Must not push new history entries. */ + redo(): void; +} + +/** Options for {@link createHistory}. */ +export interface CreateHistoryOptions { + /** Undo-entry cap (default {@link HISTORY_MAX_ENTRIES}). */ + maxEntries?: number; + /** Total-byte cap across both stacks (default {@link HISTORY_BYTE_BUDGET}). */ + byteBudget?: number; +} + +/** The imperative history handle. */ +export interface History { + /** Records a new entry (clearing the redo stack) and enforces the budgets. No-op while applying. */ + push(entry: HistoryEntry): void; + /** + * Replaces the most recent undo entry in place (adjusting the byte total), + * used to coalesce a rapid burst of same-target edits — e.g. arrow-key nudges — + * into a single reversible step. Falls back to {@link push} when the undo stack + * is empty. Clears the redo stack like `push`. No-op while applying. + */ + amendLast(entry: HistoryEntry): void; + /** Reverts the most recent entry (moving it onto the redo stack). No-op when empty or already applying. */ + undo(): void; + /** Re-applies the most recently undone entry (moving it back onto the undo stack). No-op when empty or applying. */ + redo(): void; + /** True when there is at least one entry that can be undone. */ + canUndo(): boolean; + /** True when there is at least one entry that can be redone. */ + canRedo(): boolean; + /** True while an entry's `undo`/`redo` is executing (re-entrancy guard). */ + isApplying(): boolean; + /** Drops both stacks (document replace / project switch / snapshot restore). */ + clear(): void; + /** Current retained bytes across undo and redo stacks. */ + byteSize(): number; + /** Evicts oldest entries until retained bytes are at or below `budgetBytes`. */ + trimToBytes(budgetBytes: number): void; + /** Subscribes to any change in `canUndo`/`canRedo`. Returns an unsubscribe function. */ + subscribe(listener: () => void): () => void; +} + +/** Creates a bounded history stack. */ +export const createHistory = (opts: CreateHistoryOptions = {}): History => { + const maxEntries = Math.max(1, opts.maxEntries ?? HISTORY_MAX_ENTRIES); + const byteBudget = Math.max(0, opts.byteBudget ?? HISTORY_BYTE_BUDGET); + + const undoStack: HistoryEntry[] = []; + const redoStack: HistoryEntry[] = []; + const listeners = new Set<() => void>(); + + // Running byte totals, kept in sync with the stacks so eviction is O(1) per drop. + let undoBytes = 0; + let redoBytes = 0; + // Re-entrancy flag: true while replaying an entry. + let applying = false; + + const notify = (): void => { + for (const listener of listeners) { + try { + listener(); + } catch { + // Stack mutation is already complete. One faulty observer must neither + // report a false operation failure nor block later subscribers. + } + } + }; + + const clearRedo = (): void => { + if (redoStack.length === 0) { + return; + } + redoStack.length = 0; + redoBytes = 0; + }; + + /** Evicts the oldest undo entries until BOTH budgets are satisfied. */ + const enforceBudgets = (): void => { + while (undoStack.length > maxEntries && undoStack.length > 0) { + const evicted = undoStack.shift(); + if (evicted) { + undoBytes -= evicted.bytes; + } + } + while (undoBytes + redoBytes > byteBudget && undoStack.length > 0) { + const evicted = undoStack.shift(); + if (evicted) { + undoBytes -= evicted.bytes; + } + } + }; + + const push = (entry: HistoryEntry): void => { + // Replaying an entry must never record a new one; drop it defensively. + if (applying) { + return; + } + clearRedo(); + undoStack.push(entry); + undoBytes += entry.bytes; + enforceBudgets(); + // A push always clears redo and grows undo, so both booleans may have moved + // (canUndo→true on the first push, canRedo→false when redo was non-empty). + notify(); + }; + + const amendLast = (entry: HistoryEntry): void => { + if (applying) { + return; + } + if (undoStack.length === 0) { + push(entry); + return; + } + clearRedo(); + const replaced = undoStack.pop(); + if (replaced) { + undoBytes -= replaced.bytes; + } + undoStack.push(entry); + undoBytes += entry.bytes; + enforceBudgets(); + notify(); + }; + + const undo = (): void => { + if (applying || undoStack.length === 0) { + return; + } + const entry = undoStack.at(-1); + if (!entry) { + return; + } + if (!entry.replayFailureAtomic) { + // Legacy callbacks may mutate their domain before an observer throws. + // Move first so a retry cannot apply that mutation twice. If replay + // clears history, `clear()` owns the notification and the reset wins. + undoStack.pop(); + undoBytes -= entry.bytes; + redoStack.push(entry); + redoBytes += entry.bytes; + let replayError: unknown; + let didThrow = false; + applying = true; + try { + entry.undo(); + } catch (error) { + replayError = error; + didThrow = true; + } finally { + applying = false; + } + if (redoStack.at(-1) === entry) { + notify(); + } + if (didThrow) { + // Preserve the callback's exact exception value for legacy callers. + // eslint-disable-next-line no-throw-literal + throw replayError; + } + return; + } + applying = true; + try { + entry.undo(); + } finally { + applying = false; + } + // A replay callback may intentionally clear history (for example a + // synchronous document replacement). Let that reset win; never resurrect + // the entry onto the opposite stack after its original stack changed. + if (undoStack.at(-1) !== entry) { + return; + } + // Move the entry only after replay succeeds. A fallible callback (for + // example detached raster-cache preparation) may throw before applying + // anything; keeping the stacks and byte totals untouched makes retry exact. + undoStack.pop(); + undoBytes -= entry.bytes; + redoStack.push(entry); + redoBytes += entry.bytes; + notify(); + }; + + const redo = (): void => { + if (applying || redoStack.length === 0) { + return; + } + const entry = redoStack.at(-1); + if (!entry) { + return; + } + if (!entry.replayFailureAtomic) { + redoStack.pop(); + redoBytes -= entry.bytes; + undoStack.push(entry); + undoBytes += entry.bytes; + let replayError: unknown; + let didThrow = false; + applying = true; + try { + entry.redo(); + } catch (error) { + replayError = error; + didThrow = true; + } finally { + applying = false; + } + if (undoStack.at(-1) === entry) { + notify(); + } + if (didThrow) { + // Preserve the callback's exact exception value for legacy callers. + // eslint-disable-next-line no-throw-literal + throw replayError; + } + return; + } + applying = true; + try { + entry.redo(); + } finally { + applying = false; + } + if (redoStack.at(-1) !== entry) { + return; + } + redoStack.pop(); + redoBytes -= entry.bytes; + undoStack.push(entry); + undoBytes += entry.bytes; + notify(); + }; + + const clear = (): void => { + if (undoStack.length === 0 && redoStack.length === 0) { + return; + } + undoStack.length = 0; + redoStack.length = 0; + undoBytes = 0; + redoBytes = 0; + notify(); + }; + + const trimToBytes = (budgetBytes: number): void => { + const budget = Math.max(0, budgetBytes); + let changed = false; + while (undoBytes + redoBytes > budget && undoStack.length > 0) { + const evicted = undoStack.shift(); + if (evicted) { + undoBytes -= evicted.bytes; + changed = true; + } + } + while (undoBytes + redoBytes > budget && redoStack.length > 0) { + const evicted = redoStack.shift(); + if (evicted) { + redoBytes -= evicted.bytes; + changed = true; + } + } + if (changed) { + notify(); + } + }; + + return { + amendLast, + byteSize: () => undoBytes + redoBytes, + canRedo: () => redoStack.length > 0, + canUndo: () => undoStack.length > 0, + clear, + isApplying: () => applying, + push, + redo, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + trimToBytes, + undo, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/history/imagePatch.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/imagePatch.test.ts new file mode 100644 index 00000000000..c112243e10c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/imagePatch.test.ts @@ -0,0 +1,71 @@ +import type { Rect } from '@workbench/canvas-engine/types'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createImagePatchEntry } from './imagePatch'; + +/** A fake ImageData carrier sized to `w`x`h` (byteLength = w*h*4). */ +const fakeImageData = (w: number, h: number): ImageData => + ({ colorSpace: 'srgb', data: new Uint8ClampedArray(w * h * 4), height: h, width: w }) as unknown as ImageData; + +const rect: Rect = { height: 3, width: 4, x: 5, y: 6 }; + +describe('createImagePatchEntry', () => { + it('applies before on undo and after on redo with the patch rect', () => { + const before = fakeImageData(4, 3); + const after = fakeImageData(4, 3); + const apply = vi.fn(); + + const entry = createImagePatchEntry({ after, apply, before, label: 'Brush stroke', layerId: 'L1', rect }); + + entry.undo(); + expect(apply).toHaveBeenNthCalledWith(1, 'L1', rect, before); + entry.redo(); + expect(apply).toHaveBeenNthCalledWith(2, 'L1', rect, after); + }); + + it('accounts bytes as both buffers combined', () => { + const before = fakeImageData(4, 3); // 48 bytes + const after = fakeImageData(4, 3); // 48 bytes + const entry = createImagePatchEntry({ + after, + apply: vi.fn(), + before, + label: 'Brush stroke', + layerId: 'L1', + rect, + }); + expect(entry.bytes).toBe(before.data.byteLength + after.data.byteLength); + expect(entry.bytes).toBe(96); + expect(entry.label).toBe('Brush stroke'); + }); + + it('snapshots the rect so later caller mutation does not shift the write', () => { + const before = fakeImageData(4, 3); + const after = fakeImageData(4, 3); + const apply = vi.fn(); + const mutableRect: Rect = { height: 3, width: 4, x: 5, y: 6 }; + const entry = createImagePatchEntry({ + after, + apply, + before, + label: 'Brush stroke', + layerId: 'L1', + rect: mutableRect, + }); + mutableRect.x = 999; + entry.undo(); + expect(apply).toHaveBeenCalledWith('L1', { height: 3, width: 4, x: 5, y: 6 }, before); + }); + + it('throws when an ImageData dimension disagrees with the rect', () => { + const good = fakeImageData(4, 3); + const wrong = fakeImageData(2, 3); + expect(() => + createImagePatchEntry({ after: wrong, apply: vi.fn(), before: good, label: 'x', layerId: 'L1', rect }) + ).toThrow(/does not match rect/); + expect(() => + createImagePatchEntry({ after: good, apply: vi.fn(), before: wrong, label: 'x', layerId: 'L1', rect }) + ).toThrow(/before ImageData/); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/history/imagePatch.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/imagePatch.ts new file mode 100644 index 00000000000..a7825d94118 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/imagePatch.ts @@ -0,0 +1,73 @@ +/** + * A paint-edit history entry: a before/after pair of `ImageData` over a + * LAYER-LOCAL rect, reversed by putting the pixels back into the layer's cache + * surface. Layer-local coordinates are stable across cache growth/reallocation + * (the cache's content rect origin can shift as strokes grow it), so an old + * entry replays correctly no matter how the cache has been resized since. + * + * Painting (P2.1) commits a stroke as a {@link StrokeCommittedEvent} carrying the + * `dirtyRect` plus `beforeImageData`/`afterImageData` (both sized to that rect). + * This wraps that pair into a {@link HistoryEntry}: undo puts `before`, redo puts + * `after`. The actual pixel write is delegated to an engine-provided + * {@link ImagePatchApply} so this module stays free of the cache/backend/bitmap + * store — it just owns the before/after bookkeeping and the byte accounting. + * + * Byte cost = both buffers' `byteLength`, which is what the history budget bounds. + * + * Zero React, zero DOM (ImageData is a plain data carrier here), zero import-time + * side effects. + */ + +import type { Rect } from '@workbench/canvas-engine/types'; + +import type { HistoryEntry } from './history'; + +/** + * Writes `pixels` into `layerId`'s cache at `rect`'s origin and propagates the + * edit (invalidate/version bump + mark the layer dirty for persistence). Provided + * by the engine; the entry never touches the cache or backend directly. + */ +export type ImagePatchApply = (layerId: string, rect: Rect, pixels: ImageData) => void; + +/** Options for {@link createImagePatchEntry}. */ +export interface CreateImagePatchEntryOptions { + layerId: string; + /** The painted region in LAYER-LOCAL space; its w/h must match the ImageData. */ + rect: Rect; + /** Cache pixels within `rect` before the stroke. */ + before: ImageData; + /** Cache pixels within `rect` after the stroke. */ + after: ImageData; + /** Entry label (e.g. "Brush stroke" / "Eraser stroke"). */ + label: string; + /** The engine's pixel-write bridge. */ + apply: ImagePatchApply; +} + +/** Throws if an ImageData's dimensions disagree with the patch rect. */ +const assertDims = (rect: Rect, image: ImageData, which: 'before' | 'after'): void => { + if (image.width !== rect.width || image.height !== rect.height) { + throw new Error( + `imagePatch: ${which} ImageData (${image.width}x${image.height}) does not match rect (${rect.width}x${rect.height})` + ); + } +}; + +/** Creates a reversible paint-edit entry from a committed stroke's before/after pixels. */ +export const createImagePatchEntry = (opts: CreateImagePatchEntryOptions): HistoryEntry => { + const { after, apply, before, label, layerId, rect } = opts; + assertDims(rect, before, 'before'); + assertDims(rect, after, 'after'); + + // Snapshot the rect so a later external mutation of the caller's object can't + // shift where undo/redo write. + const patchRect: Rect = { height: rect.height, width: rect.width, x: rect.x, y: rect.y }; + const bytes = before.data.byteLength + after.data.byteLength; + + return { + bytes, + label, + redo: () => apply(layerId, patchRect, after), + undo: () => apply(layerId, patchRect, before), + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/history/layerSnapshot.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/layerSnapshot.test.ts new file mode 100644 index 00000000000..39de4f79f49 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/layerSnapshot.test.ts @@ -0,0 +1,99 @@ +import type { CanvasControlLayerContract } from '@workbench/types'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createLayerSnapshotEntry, type LayerPixelSnapshot } from './layerSnapshot'; + +const fakeImageData = (width: number, height: number): ImageData => + ({ + colorSpace: 'srgb', + data: new Uint8ClampedArray(width * height * 4), + height, + width, + }) as unknown as ImageData; + +const layer = (source: CanvasControlLayerContract['source']): CanvasControlLayerContract => ({ + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: 'm', weight: 1 }, + blendMode: 'normal', + id: 'control', + isEnabled: true, + isLocked: false, + name: 'Control', + opacity: 1, + source, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: true, +}); + +const snapshot = (which: 'before' | 'after'): LayerPixelSnapshot => ({ + layer: layer( + which === 'before' + ? { image: { height: 2, imageName: 'before', width: 3 }, type: 'image' } + : { bitmap: null, offset: { x: 4, y: 5 }, type: 'paint' } + ), + pixels: fakeImageData(3, 2), + rect: { height: 2, width: 3, x: which === 'before' ? 0 : 4, y: which === 'before' ? 0 : 5 }, +}); + +describe('createLayerSnapshotEntry', () => { + it('replays exact before/after snapshots and accounts for both buffers', () => { + const before = snapshot('before'); + const after = snapshot('after'); + const apply = vi.fn(); + const entry = createLayerSnapshotEntry({ after, apply, before, label: 'Brush stroke' }); + + expect(entry.bytes).toBe(before.pixels!.data.byteLength + after.pixels!.data.byteLength + 256); + expect(entry.replayFailureAtomic).toBe(true); + entry.undo(); + entry.redo(); + expect(apply).toHaveBeenNthCalledWith(1, before); + expect(apply).toHaveBeenNthCalledWith(2, after); + }); + + it('rejects mismatched layer ids', () => { + const before = snapshot('before'); + const after = { ...snapshot('after'), layer: { ...snapshot('after').layer, id: 'other' } }; + expect(() => createLayerSnapshotEntry({ after, apply: vi.fn(), before, label: 'Edit' })).toThrow( + 'layerSnapshot: before/after layer ids differ' + ); + }); + + it('rejects pixel dimensions that do not match the rect', () => { + const before = { ...snapshot('before'), pixels: fakeImageData(1, 1) }; + expect(() => createLayerSnapshotEntry({ after: snapshot('after'), apply: vi.fn(), before, label: 'Edit' })).toThrow( + 'layerSnapshot: before pixels do not match rect' + ); + }); + + it('accepts null pixels only for an empty cache extent', () => { + const before = { ...snapshot('before'), pixels: null, rect: { height: 0, width: 0, x: 0, y: 0 } }; + expect(() => + createLayerSnapshotEntry({ after: snapshot('after'), apply: vi.fn(), before, label: 'First stroke' }) + ).not.toThrow(); + expect(() => + createLayerSnapshotEntry({ + after: snapshot('after'), + apply: vi.fn(), + before: { ...snapshot('before'), pixels: null }, + label: 'Edit', + }) + ).toThrow('layerSnapshot: before non-empty rect requires pixels'); + }); + + it('protects contract and rect snapshots from later caller mutation', () => { + const before = snapshot('before'); + const after = snapshot('after'); + const apply = vi.fn(); + const entry = createLayerSnapshotEntry({ after, apply, before, label: 'Edit' }); + before.layer.name = 'mutated'; + before.rect.x = 999; + entry.undo(); + expect(apply).toHaveBeenCalledWith( + expect.objectContaining({ + layer: expect.objectContaining({ name: 'Control' }), + rect: expect.objectContaining({ x: 0 }), + }) + ); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/history/layerSnapshot.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/layerSnapshot.ts new file mode 100644 index 00000000000..7a407444a40 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/layerSnapshot.ts @@ -0,0 +1,65 @@ +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasControlLayerContract } from '@workbench/types'; + +import type { HistoryEntry } from './history'; + +export interface LayerPixelSnapshot { + layer: CanvasControlLayerContract; + rect: Rect; + /** Null represents the exact pixel state of a zero-width or zero-height cache. */ + pixels: ImageData | null; +} + +export type LayerPixelSnapshotApply = (snapshot: LayerPixelSnapshot) => void; + +export interface CreateLayerSnapshotEntryOptions { + before: LayerPixelSnapshot; + after: LayerPixelSnapshot; + label: string; + apply: LayerPixelSnapshotApply; +} + +const assertSnapshot = (snapshot: LayerPixelSnapshot, which: 'before' | 'after'): void => { + if (snapshot.rect.width === 0 || snapshot.rect.height === 0) { + if (snapshot.pixels !== null) { + throw new Error(`layerSnapshot: ${which} empty rect requires null pixels`); + } + return; + } + if (snapshot.pixels === null) { + throw new Error(`layerSnapshot: ${which} non-empty rect requires pixels`); + } + if (snapshot.pixels.width !== snapshot.rect.width || snapshot.pixels.height !== snapshot.rect.height) { + throw new Error(`layerSnapshot: ${which} pixels do not match rect`); + } +}; + +export const createLayerSnapshotEntry = ({ + after, + apply, + before, + label, +}: CreateLayerSnapshotEntryOptions): HistoryEntry => { + if (before.layer.id !== after.layer.id) { + throw new Error('layerSnapshot: before/after layer ids differ'); + } + assertSnapshot(before, 'before'); + assertSnapshot(after, 'after'); + const beforeSnapshot: LayerPixelSnapshot = { + layer: structuredClone(before.layer), + pixels: before.pixels, + rect: { ...before.rect }, + }; + const afterSnapshot: LayerPixelSnapshot = { + layer: structuredClone(after.layer), + pixels: after.pixels, + rect: { ...after.rect }, + }; + return { + bytes: (before.pixels?.data.byteLength ?? 0) + (after.pixels?.data.byteLength ?? 0) + 256, + label, + redo: () => apply(afterSnapshot), + replayFailureAtomic: true, + undo: () => apply(beforeSnapshot), + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/importBoundaries.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/importBoundaries.test.ts new file mode 100644 index 00000000000..80ce2d192b4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/importBoundaries.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +const forbiddenPrefixes = [ + '@workbench/backend', + '@workbench/canvas-operations', + '@workbench/generation', + '@workbench/widgets', +]; + +const modules = import.meta.glob('./**/*.ts', { eager: true, import: 'default', query: '?raw' }); + +const violations = (): string[] => { + const result: string[] = []; + for (const [file, loaded] of Object.entries(modules)) { + if (file.endsWith('.test.ts') || typeof loaded !== 'string') { + continue; + } + const source = loaded; + for (const match of source.matchAll(/from\s+['"]([^'"]+)['"]/g)) { + const specifier = match[1]; + if (specifier && forbiddenPrefixes.some((prefix) => specifier.startsWith(prefix))) { + result.push(`${file.slice(2)} -> ${specifier}`); + } + } + } + return result.sort(); +}; + +describe('canvas-engine import boundary', () => { + it('does not import application workflows, widgets, generation graphs, or backend networking', () => { + expect(violations()).toEqual([]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.test.ts new file mode 100644 index 00000000000..d6947d58d86 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.test.ts @@ -0,0 +1,510 @@ +import type { PointerPipelineDeps } from '@workbench/canvas-engine/input/pointerPipeline'; +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput, ToolId } from '@workbench/canvas-engine/types'; + +import { createPointerPipeline } from '@workbench/canvas-engine/input/pointerPipeline'; +import { createViewport } from '@workbench/canvas-engine/viewport'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// ---- Fakes ----------------------------------------------------------------- + +interface FakePointerInit { + button?: number; + buttons?: number; + clientX?: number; + clientY?: number; + pointerId?: number; + pointerType?: string; + pressure?: number; + ctrlKey?: boolean; + altKey?: boolean; + coalesced?: FakePointerInit[]; +} + +const makePointerEvent = (init: FakePointerInit = {}): PointerEvent => { + const self: Record = { + altKey: init.altKey ?? false, + button: init.button ?? 0, + buttons: init.buttons ?? 1, + clientX: init.clientX ?? 0, + clientY: init.clientY ?? 0, + ctrlKey: init.ctrlKey ?? false, + metaKey: false, + pointerId: init.pointerId ?? 1, + pointerType: init.pointerType ?? 'mouse', + preventDefault: vi.fn(), + pressure: init.pressure ?? 0, + shiftKey: false, + timeStamp: 0, + }; + self.getCoalescedEvents = () => (init.coalesced ?? []).map(makePointerEvent); + return self as unknown as PointerEvent; +}; + +const makeKeyEvent = (init: { + code?: string; + key?: string; + repeat?: boolean; + shiftKey?: boolean; + target?: unknown; +}): KeyboardEvent => + ({ + code: init.code ?? '', + key: init.key ?? '', + preventDefault: vi.fn(), + repeat: init.repeat ?? false, + shiftKey: init.shiftKey ?? false, + target: init.target ?? null, + }) as unknown as KeyboardEvent; + +const createHarness = ( + opts: { + tools?: ToolId[]; + handleEscape?: (o: { gestureWasActive: boolean }) => void; + maybeCommitModalSession?: () => boolean; + } = {} +) => { + const registered = new Set(opts.tools ?? ['view', 'brush']); + const tool: Tool & { + downs: PointerInput[]; + moves: { input: PointerInput; batch: readonly PointerInput[] }[]; + ups: PointerInput[]; + cancels: number; + } = { + cancels: 0, + downs: [], + id: 'brush', + moves: [], + onPointerCancel: () => { + tool.cancels += 1; + }, + onPointerDown: (_ctx, input) => { + tool.downs.push(input); + }, + onPointerMove: (_ctx, input, batch) => { + tool.moves.push({ batch, input }); + }, + onPointerUp: (_ctx, input) => { + tool.ups.push(input); + }, + ups: [], + }; + + const captures: number[] = []; + const releases: number[] = []; + const element = { + getBoundingClientRect: () => ({ left: 0, top: 0 }) as DOMRect, + releasePointerCapture: (id: number) => releases.push(id), + setPointerCapture: (id: number) => captures.push(id), + } as unknown as HTMLElement; + + let activeToolId: ToolId = 'brush'; + const setTool = vi.fn((id: ToolId) => { + activeToolId = id; + }); + const ctx = {} as ToolContext; + + const deps: PointerPipelineDeps = { + getActiveTool: () => tool, + getActiveToolId: () => activeToolId, + getInputElement: () => element, + getToolContext: () => ctx, + handleEscape: opts.handleEscape, + hasTool: (id) => registered.has(id), + maybeCommitModalSession: opts.maybeCommitModalSession, + setTool, + updateCursor: vi.fn(), + viewport: createViewport(), + }; + + return { captures, deps, pipeline: createPointerPipeline(deps), releases, setTool, tool }; +}; + +// ---- Tests ----------------------------------------------------------------- + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('pointer pipeline: pressure + capture', () => { + it('captures the pointer on primary down and defaults mouse pressure to 0.5', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 7, pointerType: 'mouse', pressure: 0 })); + + expect(h.captures).toEqual([7]); + expect(h.tool.downs).toHaveLength(1); + expect(h.tool.downs[0]!.pressure).toBe(0.5); + }); + + it('preserves reported pen pressure', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerType: 'pen', pressure: 0.73 })); + expect(h.tool.downs[0]!.pressure).toBeCloseTo(0.73); + expect(h.tool.downs[0]!.pointerType).toBe('pen'); + }); + + it('releases capture on pointer up and forwards the up sample', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 3 })); + h.pipeline.onPointerUp(makePointerEvent({ buttons: 0, pointerId: 3 })); + expect(h.releases).toEqual([3]); + expect(h.tool.ups).toHaveLength(1); + }); +}); + +describe('pointer pipeline: coalesced batching', () => { + it('expands coalesced events into a batch whose last element is the primary sample', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({})); + h.pipeline.onPointerMove( + makePointerEvent({ + clientX: 30, + coalesced: [ + { clientX: 10, clientY: 10 }, + { clientX: 20, clientY: 15 }, + { clientX: 30, clientY: 20 }, + ], + }) + ); + + expect(h.tool.moves).toHaveLength(1); + const { batch, input } = h.tool.moves[0]!; + expect(batch).toHaveLength(3); + expect(input).toBe(batch[2]); + expect(batch[0]!.screenPoint).toEqual({ x: 10, y: 10 }); + }); + + it('falls back to a single-sample batch when no coalesced events exist', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({})); + h.pipeline.onPointerMove(makePointerEvent({ clientX: 42, coalesced: [] })); + expect(h.tool.moves[0]!.batch).toHaveLength(1); + expect(h.tool.moves[0]!.input.screenPoint.x).toBe(42); + }); +}); + +describe('pointer pipeline: modal (text-edit) session commit on pointerdown', () => { + it('commits and swallows a primary press when a modal session is open (no capture/gesture/tool routing)', () => { + const maybeCommitModalSession = vi.fn(() => true); + const h = createHarness({ maybeCommitModalSession }); + const event = makePointerEvent({ pointerId: 5 }); + + h.pipeline.onPointerDown(event); + + expect(maybeCommitModalSession).toHaveBeenCalledTimes(1); + expect(h.captures).toEqual([]); // no pointer capture + expect(h.tool.downs).toHaveLength(0); // not routed to the active tool + expect(h.pipeline.isGestureActive()).toBe(false); // ran before the gesture flag is set + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('starts a normal gesture when no modal session is open (returns false)', () => { + const maybeCommitModalSession = vi.fn(() => false); + const h = createHarness({ maybeCommitModalSession }); + + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 6 })); + + expect(maybeCommitModalSession).toHaveBeenCalledTimes(1); + expect(h.tool.downs).toHaveLength(1); + expect(h.pipeline.isGestureActive()).toBe(true); + expect(h.captures).toEqual([6]); + }); +}); + +describe('pointer pipeline: Escape priority (handleEscape)', () => { + it('runs handleEscape with gestureWasActive=false when Escape is pressed idle', () => { + const handleEscape = vi.fn(); + const h = createHarness({ handleEscape }); + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Escape' })); + expect(handleEscape).toHaveBeenCalledTimes(1); + expect(handleEscape).toHaveBeenCalledWith({ gestureWasActive: false }); + }); + + it('cancels the active gesture AND flags it to handleEscape (gestureWasActive=true)', () => { + const handleEscape = vi.fn(); + const h = createHarness({ handleEscape }); + h.pipeline.onPointerDown(makePointerEvent({})); + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Escape' })); + expect(h.tool.cancels).toBe(1); + expect(handleEscape).toHaveBeenCalledWith({ gestureWasActive: true }); + }); + + it('does not run handleEscape when Escape targets an editable field', () => { + const handleEscape = vi.fn(); + const h = createHarness({ handleEscape }); + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Escape', target: { tagName: 'INPUT' } })); + expect(handleEscape).not.toHaveBeenCalled(); + }); +}); + +describe('pointer pipeline: gesture cancel + secondary buttons', () => { + it('routes pointercancel to the tool and releases capture', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 5 })); + h.pipeline.onPointerCancel(makePointerEvent({ pointerId: 5 })); + expect(h.tool.cancels).toBe(1); + expect(h.releases).toEqual([5]); + }); + + it('cancels the active gesture on Escape', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 9 })); + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Escape' })); + expect(h.tool.cancels).toBe(1); + // A subsequent up is ignored (the gesture already ended). + h.pipeline.onPointerUp(makePointerEvent({ buttons: 0, pointerId: 9 })); + expect(h.tool.ups).toHaveLength(0); + }); + + it('ignores secondary-button presses during an active gesture', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ button: 0 })); + h.pipeline.onPointerDown(makePointerEvent({ button: 2, buttons: 3 })); + expect(h.tool.downs).toHaveLength(1); + }); + + it('ignores a standalone secondary-button press', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ button: 2, buttons: 2 })); + expect(h.tool.downs).toHaveLength(0); + expect(h.captures).toHaveLength(0); + }); +}); + +describe('pointer pipeline: middle-mouse pan', () => { + it('pans the viewport on middle drag without routing to the tool', () => { + const h = createHarness(); + const panBy = vi.spyOn(h.deps.viewport, 'panBy'); + h.pipeline.onPointerDown(makePointerEvent({ button: 1, buttons: 4, clientX: 0 })); + h.pipeline.onPointerMove(makePointerEvent({ buttons: 4, clientX: 25 })); + expect(panBy).toHaveBeenCalledWith({ x: 25, y: 0 }); + expect(h.tool.moves).toHaveLength(0); + }); +}); + +describe('pointer pipeline: multi-pointer isolation during an active gesture', () => { + it('ignores move events from a second pointer while the first pointer is gesturing', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 1 })); + h.pipeline.onPointerMove(makePointerEvent({ pointerId: 2, clientX: 99 })); + expect(h.tool.moves).toHaveLength(0); + h.pipeline.onPointerMove(makePointerEvent({ pointerId: 1, clientX: 10 })); + expect(h.tool.moves).toHaveLength(1); + }); + + it('ignores a second pointer up but ends the gesture on the first pointer up', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 1 })); + h.pipeline.onPointerUp(makePointerEvent({ buttons: 0, pointerId: 2 })); + expect(h.tool.ups).toHaveLength(0); + expect(h.releases).toHaveLength(0); + h.pipeline.onPointerUp(makePointerEvent({ buttons: 0, pointerId: 1 })); + expect(h.tool.ups).toHaveLength(1); + expect(h.releases).toEqual([1]); + }); + + it('ignores cancel events from a second pointer during an active gesture', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 1 })); + h.pipeline.onPointerCancel(makePointerEvent({ pointerId: 2 })); + expect(h.tool.cancels).toBe(0); + h.pipeline.onPointerCancel(makePointerEvent({ pointerId: 1 })); + expect(h.tool.cancels).toBe(1); + }); + + it('ignores a second pointerdown while a gesture is active', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 1 })); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 2 })); + expect(h.tool.downs).toHaveLength(1); + expect(h.captures).toEqual([1]); + }); +}); + +describe('pointer pipeline: gesture-active state', () => { + it('reports the primary-button gesture from down to up', () => { + const h = createHarness(); + expect(h.pipeline.isGestureActive()).toBe(false); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 1 })); + expect(h.pipeline.isGestureActive()).toBe(true); + h.pipeline.onPointerMove(makePointerEvent({ pointerId: 1, clientX: 10 })); + expect(h.pipeline.isGestureActive()).toBe(true); + h.pipeline.onPointerUp(makePointerEvent({ buttons: 0, pointerId: 1 })); + expect(h.pipeline.isGestureActive()).toBe(false); + }); + + it('clears on pointercancel, Escape, and reset', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 1 })); + h.pipeline.onPointerCancel(makePointerEvent({ pointerId: 1 })); + expect(h.pipeline.isGestureActive()).toBe(false); + + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 2 })); + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Escape' })); + expect(h.pipeline.isGestureActive()).toBe(false); + + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 3 })); + h.pipeline.reset(); + expect(h.pipeline.isGestureActive()).toBe(false); + }); + + it('does not report a middle-mouse pan as a gesture', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ button: 1, buttons: 4 })); + expect(h.pipeline.isGestureActive()).toBe(false); + }); +}); + +describe('pointer pipeline: cancelActiveGesture + reset run onPointerCancel', () => { + it('cancelActiveGesture releases capture and runs the tool cancel once; a no-op with no gesture', () => { + const h = createHarness(); + // No gesture: a no-op (the tool cancel must not fire). + h.pipeline.cancelActiveGesture(); + expect(h.tool.cancels).toBe(0); + + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 5 })); + h.pipeline.cancelActiveGesture(); + expect(h.tool.cancels).toBe(1); + expect(h.releases).toContain(5); + expect(h.pipeline.isGestureActive()).toBe(false); + + // A second call is a no-op — the gesture is already cancelled. + h.pipeline.cancelActiveGesture(); + expect(h.tool.cancels).toBe(1); + }); + + it('reset cancels an in-flight gesture (runs onPointerCancel + releases capture), unlike a bare flag clear', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 9 })); + expect(h.pipeline.isGestureActive()).toBe(true); + + h.pipeline.reset(); + expect(h.tool.cancels).toBe(1); + expect(h.releases).toContain(9); + expect(h.pipeline.isGestureActive()).toBe(false); + }); +}); + +describe('pointer pipeline: temporary modifier tools', () => { + it('holds view while space is down and restores the prior tool on release', () => { + const h = createHarness(); + h.pipeline.onPointerEnter(); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'Space' })); + // Flagged `temporary` so a session-bearing tool (transform) can tell this + // apart from a real switch and keep its session alive across the hold. + expect(h.setTool).toHaveBeenLastCalledWith('view', { temporary: true }); + h.pipeline.onKeyUp(makeKeyEvent({ code: 'Space' })); + expect(h.setTool).toHaveBeenLastCalledWith('brush', { temporary: true }); + }); + + it('replaces a matching pending restore target when its session closes', () => { + const h = createHarness({ tools: ['view', 'brush', 'sam'] }); + h.deps.setTool('sam'); + h.pipeline.onPointerEnter(); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'Space' })); + expect(h.setTool).toHaveBeenLastCalledWith('view', { temporary: true }); + + h.pipeline.replaceTemporaryRestoreTool('sam', 'view'); + h.pipeline.onKeyUp(makeKeyEvent({ code: 'Space' })); + + expect(h.setTool).toHaveBeenLastCalledWith('view', { temporary: true }); + expect(h.setTool).not.toHaveBeenCalledWith('sam', { temporary: true }); + }); + + it('does not replace an unrelated temporary restore target', () => { + const h = createHarness({ tools: ['view', 'brush', 'sam'] }); + h.pipeline.onPointerEnter(); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'Space' })); + + h.pipeline.replaceTemporaryRestoreTool('sam', 'view'); + h.pipeline.onKeyUp(makeKeyEvent({ code: 'Space' })); + + expect(h.setTool).toHaveBeenLastCalledWith('brush', { temporary: true }); + }); + + it('does not switch on space when the pointer is not over the canvas', () => { + const h = createHarness(); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'Space' })); + expect(h.setTool).not.toHaveBeenCalled(); + }); + + it('alt-hold is a no-op switch when colorPicker is not registered', () => { + const h = createHarness({ tools: ['view', 'brush'] }); + h.pipeline.onPointerEnter(); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'AltLeft' })); + h.pipeline.onKeyUp(makeKeyEvent({ code: 'AltLeft' })); + expect(h.setTool).not.toHaveBeenCalled(); + }); + + it('alt-hold switches to colorPicker when registered and restores on release', () => { + const h = createHarness({ tools: ['view', 'brush', 'colorPicker'] }); + h.pipeline.onPointerEnter(); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'AltLeft' })); + expect(h.setTool).toHaveBeenLastCalledWith('colorPicker', { temporary: true }); + h.pipeline.onKeyUp(makeKeyEvent({ code: 'AltLeft' })); + expect(h.setTool).toHaveBeenLastCalledWith('brush', { temporary: true }); + }); + + it('quick-tapping C sticky-selects the bbox tool after the temporary preview switch', () => { + const h = createHarness({ tools: ['view', 'brush', 'bbox'] }); + h.pipeline.onPointerEnter(); + + h.pipeline.onKeyDown(makeKeyEvent({ code: 'KeyC', key: 'c' })); + expect(h.setTool).toHaveBeenLastCalledWith('bbox', { temporary: true }); + + h.pipeline.onKeyUp(makeKeyEvent({ code: 'KeyC', key: 'c' })); + expect(h.setTool).toHaveBeenLastCalledWith('bbox'); + expect(h.setTool.mock.calls.slice(-2)).toEqual([['brush', { temporary: true }], ['bbox']]); + }); + + it('leaves Shift+C to the registered clear-mask hotkey', () => { + const h = createHarness({ tools: ['view', 'brush', 'bbox'] }); + h.pipeline.onPointerEnter(); + + h.pipeline.onKeyDown(makeKeyEvent({ code: 'KeyC', key: 'C', shiftKey: true })); + h.pipeline.onKeyUp(makeKeyEvent({ code: 'KeyC', key: 'C', shiftKey: true })); + + expect(h.setTool).not.toHaveBeenCalled(); + }); + + it('holding C restores the prior tool on release without sticky-selecting bbox', () => { + vi.useFakeTimers(); + const h = createHarness({ tools: ['view', 'brush', 'bbox'] }); + h.pipeline.onPointerEnter(); + + h.pipeline.onKeyDown(makeKeyEvent({ code: 'KeyC', key: 'c' })); + vi.runAllTimers(); + h.pipeline.onKeyUp(makeKeyEvent({ code: 'KeyC', key: 'c' })); + + expect(h.setTool).toHaveBeenLastCalledWith('brush', { temporary: true }); + }); + + it('keeps temporary bbox active until pointerup when C is released mid-gesture', () => { + const h = createHarness({ tools: ['view', 'brush', 'bbox'] }); + h.pipeline.onPointerEnter(); + + h.pipeline.onKeyDown(makeKeyEvent({ code: 'KeyC', key: 'c' })); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 11 })); + h.pipeline.onKeyUp(makeKeyEvent({ code: 'KeyC', key: 'c' })); + expect(h.setTool).toHaveBeenLastCalledWith('bbox', { temporary: true }); + + h.pipeline.onPointerUp(makePointerEvent({ buttons: 0, pointerId: 11 })); + expect(h.setTool).toHaveBeenLastCalledWith('brush', { temporary: true }); + }); + + it('ignores space/alt holds when the key event targets an editable element', () => { + const h = createHarness({ tools: ['view', 'brush', 'colorPicker'] }); + h.pipeline.onPointerEnter(); + + // Typing space/alt in an input, textarea, or contenteditable belongs to that + // field, not the canvas — the temp-tool hold must not fire. + h.pipeline.onKeyDown(makeKeyEvent({ code: 'Space', target: { tagName: 'INPUT' } })); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'Space', target: { tagName: 'textarea' } })); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'AltLeft', target: { isContentEditable: true } })); + expect(h.setTool).not.toHaveBeenCalled(); + + // A non-editable target (e.g. the canvas) still triggers the hold. + h.pipeline.onKeyDown(makeKeyEvent({ code: 'Space', target: { tagName: 'CANVAS' } })); + expect(h.setTool).toHaveBeenLastCalledWith('view', { temporary: true }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.ts new file mode 100644 index 00000000000..322b974aea5 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.ts @@ -0,0 +1,458 @@ +/** + * The pointer pipeline: normalizes raw DOM pointer/key events into the engine's + * {@link PointerInput} vocabulary and routes them to the active tool. + * + * Responsibilities lifted out of the engine so `engine.ts` stays lean: + * - Pointer capture on down; `getCoalescedEvents()` batching on move (so fast + * strokes keep every intermediate sample); mouse pressure defaulted to 0.5. + * - Middle-mouse pan (engine-level, tool-independent). + * - Modifier/key-hold temporary tools: space → view, alt → colorPicker, hold C + * → bbox (quick-tap C still sticky-selects bbox). The prior tool is restored + * on release. Temp switches are suppressed mid-gesture, and are + * flagged `{ temporary: true }` on `setTool` so a session-bearing tool + * (transform) can tell them apart from a real switch and keep its session + * alive across the hold. + * - Gesture cancellation: pointercancel and Esc route to the tool's + * `onPointerCancel`; secondary/extra buttons are ignored during a gesture. + * + * The pipeline reaches the DOM only through the injected `getInputElement` + * (for pointer capture / element rect) and the events passed to its handlers, so + * it is fully driveable by a fake harness in node tests. Zero React, zero + * import-time side effects. + */ + +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput, ToolId, Vec2 } from '@workbench/canvas-engine/types'; +import type { Viewport } from '@workbench/canvas-engine/viewport'; + +/** The tool id temporarily activated while alt is held (ships in Task P2.4). */ +const ALT_TEMP_TOOL: ToolId = 'colorPicker'; +/** The tool id temporarily activated while space is held. */ +const SPACE_TEMP_TOOL: ToolId = 'view'; +/** The tool id temporarily activated while the bbox key is held. */ +const BBOX_TEMP_TOOL: ToolId = 'bbox'; +/** Key-up before this threshold keeps the bbox tool selected, preserving the old tap behavior. */ +const BBOX_QUICK_TAP_MS = 180; + +/** Dependencies injected by the engine. */ +export interface PointerPipelineDeps { + viewport: Viewport; + /** The element that owns pointer capture and defines the coordinate rect. */ + getInputElement(): (HTMLElement & Partial>) | null; + getActiveTool(): Tool | undefined; + getActiveToolId(): ToolId; + getToolContext(): ToolContext; + /** + * Switches the active tool. `opts.temporary` marks a modifier-hold switch + * (and its matching restore) so a session-bearing tool's `onActivate`/ + * `onDeactivate` can preserve its session instead of tearing it down — see + * {@link beginTempTool} / {@link endTempTool}. + */ + setTool(id: ToolId, opts?: { temporary?: boolean }): void; + hasTool(id: ToolId): boolean; + updateCursor(): void; + /** + * The engine's Escape priority, run AFTER the in-flight gesture is cancelled + * (and skipped in editable fields): cancel a transform session, else deselect. + * `gestureWasActive` tells it a drag just consumed this Escape, so it should + * cancel a session (session teardown is wanted mid-drag, matching the prior + * behavior) but NOT also deselect. Optional so minimal harnesses can omit it. + * See `engine.ts` `handleEscape`. + */ + handleEscape?(opts: { gestureWasActive: boolean }): void; + /** + * Called on a primary-button pointerdown BEFORE any gesture starts. Returns + * `true` if the engine consumed the press to commit an open modal session (a + * text-edit session), in which case the pipeline swallows the press entirely — + * no capture, no gesture, no tool routing — because the click's sole job was to + * close the session (the next press then starts a fresh interaction). Running + * before `gestureActive` is set is what lets the commit through the engine's + * mid-gesture guard. Optional so minimal harnesses can omit it. + */ + maybeCommitModalSession?(): boolean; +} + +/** The pipeline handle: DOM handlers plus lifecycle reset. */ +export interface PointerPipeline { + onPointerDown(event: PointerEvent): void; + onPointerMove(event: PointerEvent): void; + onPointerUp(event: PointerEvent): void; + onPointerCancel(event: PointerEvent): void; + onPointerEnter(): void; + onPointerLeave(): void; + onKeyDown(event: KeyboardEvent): void; + onKeyUp(event: KeyboardEvent): void; + /** + * True while a primary-button paint/drag gesture is mid-stroke (pointer down, + * not yet up/cancel). The engine consults this to no-op undo/redo during a + * live stroke, so a mid-gesture mod+z can't inject pixels under the session. + */ + isGestureActive(): boolean; + /** + * Cancels an in-flight primary-button gesture the same way Esc/pointercancel + * do: releases pointer capture, runs the active tool's `onPointerCancel` (so it + * drops its own transient state), and refreshes the cursor. A no-op when no + * gesture is active. The engine calls this on a wholesale document replacement + * so a mid-drag swap can't commit against the outgoing document on pointer-up. + */ + cancelActiveGesture(): void; + /** Replaces a matching tool id that a currently-held temporary tool would restore on release. */ + replaceTemporaryRestoreTool(current: ToolId, replacement: ToolId): void; + /** Clears hover/gesture/temp-tool state, cancelling any in-flight gesture (called on detach/blur). */ + reset(): void; +} + +const toPointerType = (type: string): PointerInput['pointerType'] => + type === 'pen' ? 'pen' : type === 'touch' ? 'touch' : 'mouse'; + +const isAltKey = (event: KeyboardEvent): boolean => event.code === 'AltLeft' || event.code === 'AltRight'; +const isBboxKey = (event: KeyboardEvent): boolean => + event.code === 'KeyC' && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey; + +/** + * True when the key event targets an editable element (text input, textarea, or + * a contenteditable node). The space/alt temp-tool holds are window-level, so + * without this guard typing a space in a rename field would hijack the canvas + * view tool. Duck-typed on `tagName`/`isContentEditable` so it stays node-safe + * (no `instanceof HTMLElement`, which throws where those globals are absent). + */ +const isEditableTarget = (target: EventTarget | null): boolean => { + const el = target as { tagName?: unknown; isContentEditable?: unknown } | null; + if (!el) { + return false; + } + const tagName = typeof el.tagName === 'string' ? el.tagName.toUpperCase() : ''; + return tagName === 'INPUT' || tagName === 'TEXTAREA' || el.isContentEditable === true; +}; + +/** Creates a pointer pipeline bound to the engine's injected deps. */ +export const createPointerPipeline = (deps: PointerPipelineDeps): PointerPipeline => { + let hovered = false; + // Primary-button paint/drag gesture in progress. + let gestureActive = false; + let activePointerId: number | null = null; + // Middle-mouse pan. + let middlePanning = false; + let middleLast: Vec2 | null = null; + // Temporary modifier-hold tool. + let tempHold: 'space' | 'alt' | 'bbox' | null = null; + let priorToolId: ToolId = deps.getActiveToolId(); + let tempSwitched = false; + let restoreTempAfterGesture = false; + let bboxQuickTap = false; + let bboxTapTimer: ReturnType | null = null; + + const buildPointerInput = (event: PointerEvent): PointerInput => { + const el = deps.getInputElement(); + const rect = el?.getBoundingClientRect() ?? ({ left: 0, top: 0 } as DOMRect); + const screenPoint: Vec2 = { x: event.clientX - rect.left, y: event.clientY - rect.top }; + return { + buttons: event.buttons, + documentPoint: deps.viewport.screenToDocument(screenPoint), + modifiers: { alt: event.altKey, ctrl: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey }, + pointerType: toPointerType(event.pointerType), + pressure: event.pressure > 0 ? event.pressure : 0.5, + screenPoint, + timeStamp: event.timeStamp, + }; + }; + + const buildBatch = (event: PointerEvent): PointerInput[] => { + const coalesced = event.getCoalescedEvents?.(); + if (coalesced && coalesced.length > 0) { + return coalesced.map(buildPointerInput); + } + return [buildPointerInput(event)]; + }; + + const releaseCapture = (pointerId: number): void => { + deps.getInputElement()?.releasePointerCapture?.(pointerId); + }; + + const cancelGesture = (): void => { + if (!gestureActive) { + return; + } + gestureActive = false; + if (activePointerId !== null) { + releaseCapture(activePointerId); + activePointerId = null; + } + deps.getActiveTool()?.onPointerCancel?.(deps.getToolContext()); + deps.updateCursor(); + if (restoreTempAfterGesture && tempHold) { + endTempTool(); + } + }; + + const clearBboxTapTimer = (): void => { + if (bboxTapTimer !== null) { + clearTimeout(bboxTapTimer); + bboxTapTimer = null; + } + }; + + const markBboxHold = (): void => { + if (tempHold !== 'bbox') { + return; + } + bboxQuickTap = false; + clearBboxTapTimer(); + }; + + const beginTempTool = (hold: 'space' | 'alt', toolId: ToolId): void => { + if (tempHold || gestureActive || !hovered) { + return; + } + tempHold = hold; + priorToolId = deps.getActiveToolId(); + if (deps.hasTool(toolId)) { + deps.setTool(toolId, { temporary: true }); + tempSwitched = true; + } else { + tempSwitched = false; + } + }; + + const beginBboxTempTool = (): void => { + if (tempHold || gestureActive) { + return; + } + tempHold = 'bbox'; + priorToolId = deps.getActiveToolId(); + restoreTempAfterGesture = false; + bboxQuickTap = true; + clearBboxTapTimer(); + bboxTapTimer = setTimeout(() => { + bboxQuickTap = false; + bboxTapTimer = null; + }, BBOX_QUICK_TAP_MS); + + if (hovered && deps.hasTool(BBOX_TEMP_TOOL)) { + deps.setTool(BBOX_TEMP_TOOL, { temporary: true }); + tempSwitched = true; + } else { + tempSwitched = false; + } + }; + + function endTempTool(): void { + clearBboxTapTimer(); + if (tempSwitched) { + deps.setTool(priorToolId, { temporary: true }); + } + tempHold = null; + tempSwitched = false; + restoreTempAfterGesture = false; + bboxQuickTap = false; + } + + const releaseTempTool = (): void => { + if (!tempHold) { + return; + } + if (gestureActive) { + restoreTempAfterGesture = true; + return; + } + endTempTool(); + }; + + const releaseBboxTempTool = (): void => { + if (tempHold !== 'bbox') { + return; + } + clearBboxTapTimer(); + if (gestureActive) { + bboxQuickTap = false; + restoreTempAfterGesture = true; + return; + } + if (bboxQuickTap) { + if (tempSwitched) { + deps.setTool(priorToolId, { temporary: true }); + } + deps.setTool(BBOX_TEMP_TOOL); + tempHold = null; + tempSwitched = false; + restoreTempAfterGesture = false; + bboxQuickTap = false; + return; + } + endTempTool(); + }; + + return { + cancelActiveGesture: () => { + cancelGesture(); + }, + onKeyDown: (event) => { + if (event.key === 'Escape') { + // Cancel any active drag first, then run the engine's Escape priority + // (cancel a transform session, else deselect). The editable guard keeps + // Escape in a text field from tearing down a canvas session/selection the + // field isn't part of. A mid-drag Escape cancels the gesture here; the + // subsequent `handleEscape` still sees (and cancels) a transform session + // the reverted drag kept open, matching the prior gesture+session teardown, + // but skips deselect so a mid-lasso Escape drops only the in-progress path. + const gestureWasActive = gestureActive; + cancelGesture(); + if (!isEditableTarget(event.target)) { + deps.handleEscape?.({ gestureWasActive }); + } + return; + } + // Never let space/alt temp-tool holds (or Enter apply) fire while the user is + // typing in an editable field (e.g. a layer rename input) — that key belongs + // to the field, not the canvas. + if (isEditableTarget(event.target)) { + return; + } + if (event.key === 'Enter') { + // Enter applies a session-bearing tool's edit (transform). No-op otherwise. + deps.getActiveTool()?.onKeyCommand?.(deps.getToolContext(), 'apply'); + return; + } + if (event.code === 'Space' && !event.repeat) { + beginTempTool('space', SPACE_TEMP_TOOL); + if (tempHold === 'space') { + event.preventDefault(); + } + return; + } + if (isAltKey(event) && !event.repeat) { + beginTempTool('alt', ALT_TEMP_TOOL); + return; + } + if (isBboxKey(event) && !event.repeat) { + beginBboxTempTool(); + } + }, + isGestureActive: () => gestureActive, + onKeyUp: (event) => { + if (event.code === 'Space' && tempHold === 'space') { + releaseTempTool(); + } else if (isAltKey(event) && tempHold === 'alt') { + releaseTempTool(); + } else if (isBboxKey(event) && tempHold === 'bbox') { + releaseBboxTempTool(); + } + }, + onPointerCancel: (event) => { + // Ignore cancel events from a pointer other than the one driving the active gesture/pan + // (pointer capture on the active pointer does not suppress other pointers' events). + if (activePointerId !== null && event.pointerId !== activePointerId) { + return; + } + if (middlePanning) { + releaseCapture(event.pointerId); + middlePanning = false; + middleLast = null; + activePointerId = null; + return; + } + // `cancelGesture` releases the captured pointer itself. + cancelGesture(); + }, + onPointerDown: (event) => { + // Ignore extra/secondary buttons pressed during an active gesture or pan. + if (gestureActive || middlePanning) { + return; + } + const el = deps.getInputElement(); + if (event.button === 1) { + el?.setPointerCapture?.(event.pointerId); + activePointerId = event.pointerId; + middlePanning = true; + middleLast = buildPointerInput(event).screenPoint; + return; + } + if (event.button !== 0) { + return; + } + // A primary press while a text-edit session is open commits it (engine-side, + // reading the live portal content) and is swallowed — no gesture, no tool + // routing. This must precede `gestureActive = true` so the engine's + // mid-gesture commit guard cannot drop it. `preventDefault` avoids a stray + // focus/selection default now that the session has already been closed. + if (deps.maybeCommitModalSession?.()) { + event.preventDefault(); + return; + } + markBboxHold(); + el?.setPointerCapture?.(event.pointerId); + activePointerId = event.pointerId; + gestureActive = true; + event.preventDefault(); + deps.getActiveTool()?.onPointerDown?.(deps.getToolContext(), buildPointerInput(event)); + deps.updateCursor(); + }, + onPointerEnter: () => { + hovered = true; + }, + onPointerLeave: () => { + hovered = false; + }, + onPointerMove: (event) => { + // Ignore move events from a pointer other than the one driving the active gesture/pan. + if (activePointerId !== null && event.pointerId !== activePointerId) { + return; + } + if (middlePanning && middleLast) { + const screenPoint = buildPointerInput(event).screenPoint; + deps.viewport.panBy({ x: screenPoint.x - middleLast.x, y: screenPoint.y - middleLast.y }); + middleLast = screenPoint; + return; + } + const batch = buildBatch(event); + const last = batch[batch.length - 1]; + if (!last) { + return; + } + deps.getActiveTool()?.onPointerMove?.(deps.getToolContext(), last, batch); + }, + onPointerUp: (event) => { + // Ignore up events from a pointer other than the one driving the active gesture/pan. + if (activePointerId !== null && event.pointerId !== activePointerId) { + return; + } + releaseCapture(event.pointerId); + if (middlePanning) { + middlePanning = false; + middleLast = null; + activePointerId = null; + return; + } + if (!gestureActive) { + return; + } + gestureActive = false; + activePointerId = null; + deps.getActiveTool()?.onPointerUp?.(deps.getToolContext(), buildPointerInput(event)); + deps.updateCursor(); + if (restoreTempAfterGesture && tempHold) { + endTempTool(); + } + }, + replaceTemporaryRestoreTool: (current, replacement) => { + if (tempHold && priorToolId === current) { + priorToolId = replacement; + } + }, + reset: () => { + clearBboxTapTimer(); + if (tempHold) { + endTempTool(); + } + // Cancel an in-flight gesture through the same path Esc uses, so the active + // tool's `onPointerCancel` runs and clears its transient state (rather than + // silently dropping `gestureActive` and stranding stale tool state). + cancelGesture(); + hovered = false; + gestureActive = false; + activePointerId = null; + middlePanning = false; + middleLast = null; + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/input/wheel.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/wheel.test.ts new file mode 100644 index 00000000000..b83e7df8ec0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/wheel.test.ts @@ -0,0 +1,90 @@ +import type { WheelHandlerDeps } from '@workbench/canvas-engine/input/wheel'; +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { ToolId } from '@workbench/canvas-engine/types'; + +import { createWheelHandler } from '@workbench/canvas-engine/input/wheel'; +import { createViewport } from '@workbench/canvas-engine/viewport'; +import { describe, expect, it, vi } from 'vitest'; + +const makeWheelEvent = (deltaY: number, opts: { ctrlKey?: boolean } = {}): WheelEvent => + ({ + altKey: false, + clientX: 10, + clientY: 10, + ctrlKey: opts.ctrlKey ?? false, + deltaY, + metaKey: false, + preventDefault: vi.fn(), + shiftKey: false, + }) as unknown as WheelEvent; + +const createHarness = (tool: Tool | undefined, invertBrushSizeScroll = false) => { + const viewport = createViewport(); + const stepActiveBrushSize = vi.fn(); + const invalidate = vi.fn(); + const deps: WheelHandlerDeps = { + getActiveTool: () => tool, + getInputElement: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }) as unknown as HTMLElement, + getInvertBrushSizeScroll: () => invertBrushSizeScroll, + getToolContext: () => ({}) as ToolContext, + invalidate, + stepActiveBrushSize, + viewport, + }; + return { handler: createWheelHandler(deps), invalidate, stepActiveBrushSize, viewport }; +}; + +const toolWithId = (id: ToolId, onWheel?: Tool['onWheel']): Tool => ({ id, onWheel }); + +describe('wheel handler: ctrl+wheel brush size step', () => { + it('grows the size on ctrl+wheel-up when brush is active', () => { + const h = createHarness(toolWithId('brush')); + h.handler(makeWheelEvent(-100, { ctrlKey: true })); + expect(h.stepActiveBrushSize).toHaveBeenCalledWith(1); + }); + + it('shrinks the size on ctrl+wheel-down when eraser is active', () => { + const h = createHarness(toolWithId('eraser')); + h.handler(makeWheelEvent(100, { ctrlKey: true })); + expect(h.stepActiveBrushSize).toHaveBeenCalledWith(-1); + }); + + it('inverts the step direction when the invert-scroll preference is on', () => { + const grow = createHarness(toolWithId('brush'), true); + grow.handler(makeWheelEvent(-100, { ctrlKey: true })); + // Wheel-up normally grows (+1); inverted, it shrinks (-1). + expect(grow.stepActiveBrushSize).toHaveBeenCalledWith(-1); + + const shrink = createHarness(toolWithId('eraser'), true); + shrink.handler(makeWheelEvent(100, { ctrlKey: true })); + // Wheel-down normally shrinks (-1); inverted, it grows (+1). + expect(shrink.stepActiveBrushSize).toHaveBeenCalledWith(1); + }); + + it('swallows ctrl+wheel (no size step, no zoom) for non-paint tools', () => { + const h = createHarness(toolWithId('view')); + const wheelZoom = vi.spyOn(h.viewport, 'wheelZoom'); + h.handler(makeWheelEvent(-100, { ctrlKey: true })); + expect(h.stepActiveBrushSize).not.toHaveBeenCalled(); + expect(wheelZoom).not.toHaveBeenCalled(); + }); +}); + +describe('wheel handler: plain wheel', () => { + it('zooms the viewport when the active tool defines no onWheel', () => { + const h = createHarness(toolWithId('brush')); + const wheelZoom = vi.spyOn(h.viewport, 'wheelZoom'); + h.handler(makeWheelEvent(-120)); + expect(wheelZoom).toHaveBeenCalledWith(-120, { x: 10, y: 10 }); + expect(h.invalidate).toHaveBeenCalledWith({ view: true }); + }); + + it('routes to the active tool onWheel when present', () => { + const onWheel = vi.fn(); + const h = createHarness(toolWithId('view', onWheel)); + const wheelZoom = vi.spyOn(h.viewport, 'wheelZoom'); + h.handler(makeWheelEvent(-120)); + expect(onWheel).toHaveBeenCalledTimes(1); + expect(wheelZoom).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/input/wheel.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/wheel.ts new file mode 100644 index 00000000000..96ec4770f2a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/wheel.ts @@ -0,0 +1,71 @@ +/** + * Wheel routing for the canvas. + * + * - Plain wheel → the active tool's `onWheel` if it defines one, else viewport + * zoom about the cursor (the default navigation behavior). + * - Ctrl+wheel → when the active tool is brush/eraser, step its size; otherwise + * it is swallowed (reserved for the browser pinch-zoom gesture). + * + * The handler is a pure function of injected deps (viewport, the active tool, and + * a `stepActiveBrushSize` callback the engine wires to the tool-options stores), + * so it is driven directly in node tests. DOM is touched only through the passed + * `WheelEvent`. Zero React, zero import-time side effects. + */ + +import type { InvalidatePayload } from '@workbench/canvas-engine/render/scheduler'; +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerModifiers, Vec2 } from '@workbench/canvas-engine/types'; +import type { Viewport } from '@workbench/canvas-engine/viewport'; + +/** Dependencies for {@link createWheelHandler}. */ +export interface WheelHandlerDeps { + viewport: Viewport; + invalidate(payload: InvalidatePayload): void; + getInputElement(): HTMLElement | null; + getActiveTool(): Tool | undefined; + getToolContext(): ToolContext; + /** Steps the active brush/eraser diameter by one notch (`+1` grow, `-1` shrink). */ + stepActiveBrushSize(direction: 1 | -1): void; + /** + * Whether ctrl+wheel brush sizing is inverted. `false` (default): wheel-up + * grows. `true`: wheel-up shrinks. A user preference read fresh per event. + */ + getInvertBrushSizeScroll(): boolean; +} + +const modifiersOf = (event: WheelEvent): PointerModifiers => ({ + alt: event.altKey, + ctrl: event.ctrlKey, + meta: event.metaKey, + shift: event.shiftKey, +}); + +/** Creates the canvas wheel handler. See module docs for routing. */ +export const createWheelHandler = (deps: WheelHandlerDeps): ((event: WheelEvent) => void) => { + return (event: WheelEvent): void => { + event.preventDefault(); + + const rect = deps.getInputElement()?.getBoundingClientRect(); + const screenAnchor: Vec2 = { x: event.clientX - (rect?.left ?? 0), y: event.clientY - (rect?.top ?? 0) }; + + const tool = deps.getActiveTool(); + + // Ctrl+wheel: brush/eraser size step, else reserved (pinch-zoom) — swallowed. + if (event.ctrlKey) { + if (tool && (tool.id === 'brush' || tool.id === 'eraser')) { + // Default: wheel-up (deltaY < 0) grows. The inversion preference flips it. + const grow = event.deltaY < 0; + const stepUp = grow !== deps.getInvertBrushSizeScroll(); + deps.stepActiveBrushSize(stepUp ? 1 : -1); + } + return; + } + + if (tool?.onWheel) { + tool.onWheel(deps.getToolContext(), event.deltaY, screenAnchor, modifiersOf(event)); + return; + } + deps.viewport.wheelZoom(event.deltaY, screenAnchor); + deps.invalidate({ view: true }); + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/math/mat2d.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/mat2d.test.ts new file mode 100644 index 00000000000..4ff01baa28e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/mat2d.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; + +import { applyToPoint, fromTRS, getScale, identity, invert, multiply, rotate, scale, translate } from './mat2d'; + +describe('identity', () => { + it('returns the identity matrix', () => { + expect(identity()).toEqual({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + }); + + it('applied to a point returns the same point', () => { + expect(applyToPoint(identity(), { x: 5, y: -3 })).toEqual({ x: 5, y: -3 }); + }); +}); + +describe('translate', () => { + it('shifts points by the translation vector', () => { + const m = translate(identity(), { x: 10, y: 20 }); + expect(applyToPoint(m, { x: 0, y: 0 })).toEqual({ x: 10, y: 20 }); + expect(applyToPoint(m, { x: 5, y: 5 })).toEqual({ x: 15, y: 25 }); + }); +}); + +describe('scale', () => { + it('scales points uniformly when sy is omitted', () => { + const m = scale(identity(), 2); + expect(applyToPoint(m, { x: 3, y: 4 })).toEqual({ x: 6, y: 8 }); + }); + + it('scales points non-uniformly when sy is given', () => { + const m = scale(identity(), 2, 3); + expect(applyToPoint(m, { x: 1, y: 1 })).toEqual({ x: 2, y: 3 }); + }); +}); + +describe('rotate', () => { + it('rotates a point 90 degrees clockwise', () => { + const m = rotate(identity(), Math.PI / 2); + const p = applyToPoint(m, { x: 1, y: 0 }); + expect(p.x).toBeCloseTo(0, 10); + expect(p.y).toBeCloseTo(1, 10); + }); + + it('rotating by 0 radians is a no-op', () => { + const m = rotate(identity(), 0); + expect(applyToPoint(m, { x: 7, y: -2 })).toEqual({ x: 7, y: -2 }); + }); +}); + +describe('multiply', () => { + it('composes transforms so the right operand applies first', () => { + // translate(10,0) * scale(2) applied to (1,1): scale first -> (2,2), then translate -> (12, 2) + const t = translate(identity(), { x: 10, y: 0 }); + const s = scale(identity(), 2); + const m = multiply(t, s); + expect(applyToPoint(m, { x: 1, y: 1 })).toEqual({ x: 12, y: 2 }); + }); + + it('multiplying by identity is a no-op on either side', () => { + const m = translate(scale(identity(), 3, 4), { x: 1, y: 2 }); + expect(multiply(m, identity())).toEqual(m); + expect(multiply(identity(), m)).toEqual(m); + }); +}); + +describe('invert', () => { + it('round-trips applyToPoint through a matrix and its inverse', () => { + const m = fromTRS({ x: 5, y: -3 }, Math.PI / 6, 2, 0.5); + const inv = invert(m); + expect(inv).not.toBeNull(); + const p = { x: 13, y: -4 }; + const roundTripped = applyToPoint(inv as NonNullable, applyToPoint(m, p)); + expect(roundTripped.x).toBeCloseTo(p.x, 8); + expect(roundTripped.y).toBeCloseTo(p.y, 8); + }); + + it('returns null for a singular matrix', () => { + // Zero determinant: a*d - b*c = 0 + expect(invert({ a: 1, b: 2, c: 2, d: 4, e: 0, f: 0 })).toBeNull(); + expect(invert({ a: 0, b: 0, c: 0, d: 0, e: 1, f: 1 })).toBeNull(); + }); + + it('inverting identity returns identity', () => { + const inv = invert(identity()); + expect(inv).not.toBeNull(); + const nonNullInv = inv as NonNullable; + expect(nonNullInv.a).toBeCloseTo(1, 10); + expect(nonNullInv.b).toBeCloseTo(0, 10); + expect(nonNullInv.c).toBeCloseTo(0, 10); + expect(nonNullInv.d).toBeCloseTo(1, 10); + expect(nonNullInv.e).toBeCloseTo(0, 10); + expect(nonNullInv.f).toBeCloseTo(0, 10); + }); +}); + +describe('fromTRS', () => { + it('matches manual translate . rotate . scale composition', () => { + const translation = { x: 4, y: -2 }; + const rad = 0.7; + const sx = 1.5; + const sy = 2.5; + + const t = translate(identity(), translation); + const r = rotate(identity(), rad); + const s = scale(identity(), sx, sy); + const manual = multiply(multiply(t, r), s); + const composed = fromTRS(translation, rad, sx, sy); + + expect(composed.a).toBeCloseTo(manual.a, 10); + expect(composed.b).toBeCloseTo(manual.b, 10); + expect(composed.c).toBeCloseTo(manual.c, 10); + expect(composed.d).toBeCloseTo(manual.d, 10); + expect(composed.e).toBeCloseTo(manual.e, 10); + expect(composed.f).toBeCloseTo(manual.f, 10); + }); + + it('defaults scaleY to scaleX for uniform scale', () => { + const composed = fromTRS({ x: 0, y: 0 }, 0, 3, 3); + const defaulted = fromTRS({ x: 0, y: 0 }, 0, 3); + expect(defaulted).toEqual(composed); + }); + + it('with no rotation/scale reduces to pure translation', () => { + expect(fromTRS({ x: 8, y: 9 }, 0, 1, 1)).toEqual(translate(identity(), { x: 8, y: 9 })); + }); +}); + +describe('getScale', () => { + it('returns 1 for the identity matrix', () => { + expect(getScale(identity())).toBeCloseTo(1, 10); + }); + + it('returns the scale factor for a uniformly scaled matrix', () => { + expect(getScale(scale(identity(), 3))).toBeCloseTo(3, 10); + }); + + it('returns the geometric mean for a non-uniformly scaled matrix', () => { + expect(getScale(scale(identity(), 2, 8))).toBeCloseTo(4, 10); + }); + + it('is unaffected by translation', () => { + const m = translate(scale(identity(), 2), { x: 100, y: -50 }); + expect(getScale(m)).toBeCloseTo(2, 10); + }); + + it('is unaffected by rotation for a uniformly scaled matrix', () => { + const m = rotate(scale(identity(), 5), 1.234); + expect(getScale(m)).toBeCloseTo(5, 8); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/math/mat2d.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/mat2d.ts new file mode 100644 index 00000000000..cd6b641cc29 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/mat2d.ts @@ -0,0 +1,95 @@ +/** + * Pure functions on `Mat2d`, the engine's 2D affine matrix type. + * + * Convention (matches `CanvasRenderingContext2D.setTransform`): + * ``` + * x' = a*x + c*y + e + * y' = b*x + d*y + f + * ``` + * + * No classes, no mutation — every function returns a new `Mat2d`. + */ + +import type { Mat2d, Vec2 } from '@workbench/canvas-engine/types'; + +/** Returns the identity matrix. */ +export const identity = (): Mat2d => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + +/** + * Composes two matrices such that applying the result to a point is + * equivalent to applying `b` first, then `a` (i.e. `result = a * b`, using + * the same composition order as `DOMMatrix.multiply` / `ctx.transform` + * chaining: `a.multiply(b)` applies `b`'s transform in `a`'s coordinate + * space). + */ +export const multiply = (a: Mat2d, b: Mat2d): Mat2d => ({ + a: a.a * b.a + a.c * b.b, + b: a.b * b.a + a.d * b.b, + c: a.a * b.c + a.c * b.d, + d: a.b * b.c + a.d * b.d, + e: a.a * b.e + a.c * b.f + a.e, + f: a.b * b.e + a.d * b.f + a.f, +}); + +/** Inverts a matrix. Returns `null` if the matrix is singular (determinant ~0). */ +export const invert = (m: Mat2d): Mat2d | null => { + const det = m.a * m.d - m.b * m.c; + if (Math.abs(det) < 1e-12) { + return null; + } + const invDet = 1 / det; + const a = m.d * invDet; + const b = -m.b * invDet; + const c = -m.c * invDet; + const d = m.a * invDet; + const e = -(a * m.e + c * m.f); + const f = -(b * m.e + d * m.f); + return { a, b, c, d, e, f }; +}; + +/** Returns a matrix translated by `v` (post-multiplies a translation). */ +export const translate = (m: Mat2d, v: Vec2): Mat2d => multiply(m, { a: 1, b: 0, c: 0, d: 1, e: v.x, f: v.y }); + +/** Returns a matrix scaled by `sx`/`sy` (defaults `sy` to `sx` for uniform scale). */ +export const scale = (m: Mat2d, sx: number, sy: number = sx): Mat2d => + multiply(m, { a: sx, b: 0, c: 0, d: sy, e: 0, f: 0 }); + +/** Returns a matrix rotated by `rad` radians (positive = clockwise in canvas space). */ +export const rotate = (m: Mat2d, rad: number): Mat2d => { + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return multiply(m, { a: cos, b: sin, c: -sin, d: cos, e: 0, f: 0 }); +}; + +/** Applies a matrix to a point, returning the transformed point. */ +export const applyToPoint = (m: Mat2d, p: Vec2): Vec2 => ({ + x: m.a * p.x + m.c * p.y + m.e, + y: m.b * p.x + m.d * p.y + m.f, +}); + +/** + * Composes a matrix from translation, rotation, and scale, in the order + * translate · rotate · scale — i.e. scale is applied first (in local + * space), then rotation, then translation. This is the standard TRS + * composition used for layer transforms. + */ +export const fromTRS = (translation: Vec2, rotationRad: number, scaleX: number, scaleY: number = scaleX): Mat2d => { + let m = identity(); + m = translate(m, translation); + m = rotate(m, rotationRad); + m = scale(m, scaleX, scaleY); + return m; +}; + +/** + * Extracts an approximate uniform scale magnitude from a matrix — the + * geometric mean of the transformed lengths of the unit x/y axis vectors. + * Useful for stroke-width and zoom math where an exact per-axis scale isn't + * needed. For a matrix with no shear/non-uniform scale this equals the + * true scale factor. + */ +export const getScale = (m: Mat2d): number => { + const scaleX = Math.hypot(m.a, m.b); + const scaleY = Math.hypot(m.c, m.d); + return Math.sqrt(scaleX * scaleY); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/math/rect.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/rect.test.ts new file mode 100644 index 00000000000..8888aa85487 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/rect.test.ts @@ -0,0 +1,202 @@ +import type { Rect } from '@workbench/canvas-engine/types'; + +import { describe, expect, it } from 'vitest'; + +import { identity, rotate, scale, translate } from './mat2d'; +import { containsPoint, expand, intersect, isEmpty, mergeDirtyRects, roundOut, transformBounds, union } from './rect'; + +describe('isEmpty', () => { + it('is true for zero width or height', () => { + expect(isEmpty({ x: 0, y: 0, width: 0, height: 10 })).toBe(true); + expect(isEmpty({ x: 0, y: 0, width: 10, height: 0 })).toBe(true); + }); + + it('is true for negative width or height', () => { + expect(isEmpty({ x: 0, y: 0, width: -1, height: 10 })).toBe(true); + }); + + it('is false for a positive-area rect', () => { + expect(isEmpty({ x: 0, y: 0, width: 1, height: 1 })).toBe(false); + }); +}); + +describe('intersect', () => { + it('returns the overlapping region for overlapping rects', () => { + const a: Rect = { x: 0, y: 0, width: 10, height: 10 }; + const b: Rect = { x: 5, y: 5, width: 10, height: 10 }; + expect(intersect(a, b)).toEqual({ x: 5, y: 5, width: 5, height: 5 }); + }); + + it('returns null for disjoint rects', () => { + const a: Rect = { x: 0, y: 0, width: 10, height: 10 }; + const b: Rect = { x: 20, y: 20, width: 10, height: 10 }; + expect(intersect(a, b)).toBeNull(); + }); + + it('returns null for rects that only touch at an edge', () => { + const a: Rect = { x: 0, y: 0, width: 10, height: 10 }; + const b: Rect = { x: 10, y: 0, width: 10, height: 10 }; + expect(intersect(a, b)).toBeNull(); + }); + + it('returns null when either rect is empty', () => { + const a: Rect = { x: 0, y: 0, width: 0, height: 10 }; + const b: Rect = { x: 0, y: 0, width: 10, height: 10 }; + expect(intersect(a, b)).toBeNull(); + }); +}); + +describe('union', () => { + it('returns the bounding box of two rects', () => { + const a: Rect = { x: 0, y: 0, width: 10, height: 10 }; + const b: Rect = { x: 5, y: -5, width: 10, height: 10 }; + expect(union(a, b)).toEqual({ x: 0, y: -5, width: 15, height: 15 }); + }); + + it('returns the non-empty rect when the other is empty', () => { + const a: Rect = { x: 0, y: 0, width: 0, height: 0 }; + const b: Rect = { x: 5, y: 5, width: 10, height: 10 }; + expect(union(a, b)).toEqual(b); + expect(union(b, a)).toEqual(b); + }); + + it('returns the first rect when both are empty', () => { + const a: Rect = { x: 1, y: 1, width: 0, height: 0 }; + const b: Rect = { x: 2, y: 2, width: 0, height: 0 }; + expect(union(a, b)).toEqual(a); + }); +}); + +describe('containsPoint', () => { + const r: Rect = { x: 0, y: 0, width: 10, height: 10 }; + + it('is true for points inside the rect', () => { + expect(containsPoint(r, { x: 5, y: 5 })).toBe(true); + expect(containsPoint(r, { x: 0, y: 0 })).toBe(true); + }); + + it('is false for points on the max edge (exclusive)', () => { + expect(containsPoint(r, { x: 10, y: 5 })).toBe(false); + expect(containsPoint(r, { x: 5, y: 10 })).toBe(false); + }); + + it('is false for points outside the rect', () => { + expect(containsPoint(r, { x: -1, y: 5 })).toBe(false); + expect(containsPoint(r, { x: 5, y: 20 })).toBe(false); + }); +}); + +describe('expand', () => { + it('grows the rect on all sides by margin', () => { + const r: Rect = { x: 10, y: 10, width: 10, height: 10 }; + expect(expand(r, 5)).toEqual({ x: 5, y: 5, width: 20, height: 20 }); + }); + + it('shrinks the rect for negative margin', () => { + const r: Rect = { x: 10, y: 10, width: 10, height: 10 }; + expect(expand(r, -2)).toEqual({ x: 12, y: 12, width: 6, height: 6 }); + }); +}); + +describe('roundOut', () => { + it('floors the min edges and ceils the max edges', () => { + const r: Rect = { x: 1.2, y: 1.8, width: 3.5, height: 2.1 }; + // right edge = 1.2 + 3.5 = 4.7 -> ceil 5; bottom = 1.8 + 2.1 = 3.9 -> ceil 4 + expect(roundOut(r)).toEqual({ x: 1, y: 1, width: 4, height: 3 }); + }); + + it('is a no-op for already-integer rects', () => { + const r: Rect = { x: 2, y: 3, width: 4, height: 5 }; + expect(roundOut(r)).toEqual(r); + }); + + it('handles negative coordinates', () => { + const r: Rect = { x: -1.5, y: -2.1, width: 3, height: 3 }; + // right = 1.5 -> ceil 2; bottom = 0.9 -> ceil 1 + expect(roundOut(r)).toEqual({ x: -2, y: -3, width: 4, height: 4 }); + }); +}); + +describe('transformBounds', () => { + it('returns the same rect for the identity matrix', () => { + const r: Rect = { x: 1, y: 2, width: 10, height: 20 }; + expect(transformBounds(identity(), r)).toEqual(r); + }); + + it('translates the rect for a translation matrix', () => { + const r: Rect = { x: 0, y: 0, width: 10, height: 10 }; + const m = translate(identity(), { x: 5, y: -5 }); + expect(transformBounds(m, r)).toEqual({ x: 5, y: -5, width: 10, height: 10 }); + }); + + it('scales the rect for a scale matrix', () => { + const r: Rect = { x: 0, y: 0, width: 10, height: 10 }; + const m = scale(identity(), 2); + expect(transformBounds(m, r)).toEqual({ x: 0, y: 0, width: 20, height: 20 }); + }); + + it('returns the axis-aligned bounding box of a rotated rect', () => { + const r: Rect = { x: -5, y: -5, width: 10, height: 10 }; + const m = rotate(identity(), Math.PI / 4); + const bounds = transformBounds(m, r); + const diagonal = Math.sqrt(2) * 10; + expect(bounds.width).toBeCloseTo(diagonal, 8); + expect(bounds.height).toBeCloseTo(diagonal, 8); + expect(bounds.x).toBeCloseTo(-diagonal / 2, 8); + expect(bounds.y).toBeCloseTo(-diagonal / 2, 8); + }); +}); + +describe('mergeDirtyRects', () => { + it('returns an empty array for no rects', () => { + expect(mergeDirtyRects([])).toEqual([]); + }); + + it('returns the single rect unchanged (filtering empties)', () => { + const r: Rect = { x: 0, y: 0, width: 5, height: 5 }; + expect(mergeDirtyRects([r])).toEqual([r]); + }); + + it('filters out empty rects', () => { + const r: Rect = { x: 0, y: 0, width: 5, height: 5 }; + const empty: Rect = { x: 0, y: 0, width: 0, height: 0 }; + expect(mergeDirtyRects([r, empty])).toEqual([r]); + }); + + it('merges overlapping rects into one', () => { + const a: Rect = { x: 0, y: 0, width: 10, height: 10 }; + const b: Rect = { x: 5, y: 5, width: 10, height: 10 }; + const result = mergeDirtyRects([a, b]); + expect(result).toEqual([{ x: 0, y: 0, width: 15, height: 15 }]); + }); + + it('keeps far-apart rects separate when under the region cap', () => { + const a: Rect = { x: 0, y: 0, width: 5, height: 5 }; + const b: Rect = { x: 1000, y: 1000, width: 5, height: 5 }; + const result = mergeDirtyRects([a, b]); + expect(result).toHaveLength(2); + expect(result).toEqual(expect.arrayContaining([a, b])); + }); + + it('falls back to a single merged bounding rect beyond maxRegions', () => { + const rects: Rect[] = [ + { x: 0, y: 0, width: 5, height: 5 }, + { x: 100, y: 0, width: 5, height: 5 }, + { x: 0, y: 100, width: 5, height: 5 }, + { x: 100, y: 100, width: 5, height: 5 }, + { x: 200, y: 200, width: 5, height: 5 }, + ]; + const result = mergeDirtyRects(rects, 4); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ x: 0, y: 0, width: 205, height: 205 }); + }); + + it('respects a custom maxRegions', () => { + const rects: Rect[] = [ + { x: 0, y: 0, width: 5, height: 5 }, + { x: 100, y: 0, width: 5, height: 5 }, + ]; + const result = mergeDirtyRects(rects, 1); + expect(result).toHaveLength(1); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/math/rect.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/rect.ts new file mode 100644 index 00000000000..141aa548313 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/rect.ts @@ -0,0 +1,167 @@ +/** + * Pure functions on `Rect`, the engine's axis-aligned rectangle type. + * + * No classes, no mutation — every function returns a new `Rect` (or a + * primitive / array of `Rect`). + */ + +import type { Mat2d, Rect, Vec2 } from '@workbench/canvas-engine/types'; + +import { applyToPoint } from './mat2d'; + +/** True if the rect has non-positive width or height. */ +export const isEmpty = (r: Rect): boolean => r.width <= 0 || r.height <= 0; + +/** Intersection of two rects, or `null` if they don't overlap (or either is empty). */ +export const intersect = (a: Rect, b: Rect): Rect | null => { + if (isEmpty(a) || isEmpty(b)) { + return null; + } + const x = Math.max(a.x, b.x); + const y = Math.max(a.y, b.y); + const right = Math.min(a.x + a.width, b.x + b.width); + const bottom = Math.min(a.y + a.height, b.y + b.height); + if (right <= x || bottom <= y) { + return null; + } + return { x, y, width: right - x, height: bottom - y }; +}; + +/** + * Union (bounding box) of two rects. An empty rect is treated as + * contributing no area — the union of an empty rect and a non-empty rect is + * the non-empty rect; the union of two empty rects is the first rect. + */ +export const union = (a: Rect, b: Rect): Rect => { + if (isEmpty(a) && isEmpty(b)) { + return a; + } + if (isEmpty(a)) { + return b; + } + if (isEmpty(b)) { + return a; + } + const x = Math.min(a.x, b.x); + const y = Math.min(a.y, b.y); + const right = Math.max(a.x + a.width, b.x + b.width); + const bottom = Math.max(a.y + a.height, b.y + b.height); + return { x, y, width: right - x, height: bottom - y }; +}; + +/** True if `p` lies within `r`, inclusive of the min edges and exclusive of the max edges. */ +export const containsPoint = (r: Rect, p: Vec2): boolean => + p.x >= r.x && p.x < r.x + r.width && p.y >= r.y && p.y < r.y + r.height; + +/** Grows (or shrinks, for negative `margin`) a rect uniformly on all sides. */ +export const expand = (r: Rect, margin: number): Rect => ({ + x: r.x - margin, + y: r.y - margin, + width: r.width + margin * 2, + height: r.height + margin * 2, +}); + +/** + * Rounds a rect outward to integer bounds (floor the min edges, ceil the + * max edges). Used to align dirty rects to pixel boundaries so patches + * fully cover the region that changed. + */ +export const roundOut = (r: Rect): Rect => { + const x = Math.floor(r.x); + const y = Math.floor(r.y); + const right = Math.ceil(r.x + r.width); + const bottom = Math.ceil(r.y + r.height); + return { x, y, width: right - x, height: bottom - y }; +}; + +/** Axis-aligned bounds of `r` after being transformed by `m`. */ +export const transformBounds = (m: Mat2d, r: Rect): Rect => { + const corners: Vec2[] = [ + { x: r.x, y: r.y }, + { x: r.x + r.width, y: r.y }, + { x: r.x, y: r.y + r.height }, + { x: r.x + r.width, y: r.y + r.height }, + ].map((p) => applyToPoint(m, p)); + + const xs = corners.map((p) => p.x); + const ys = corners.map((p) => p.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +}; + +/** Rect area, treating empty rects as zero area. */ +const area = (r: Rect): number => (isEmpty(r) ? 0 : r.width * r.height); + +/** + * True if two rects overlap or touch within `slack` pixels of each other + * (i.e. their `slack`-expanded bounds intersect). + */ +const isNearby = (a: Rect, b: Rect, slack: number): boolean => { + const expandedA = slack === 0 ? a : expand(a, slack); + return intersect(expandedA, b) !== null; +}; + +/** + * Coalesces a list of dirty rects into at most `maxRegions` rects. + * + * Algorithm: greedily merges any two rects that overlap or are within a + * small proximity slack (derived from the average rect size) whenever the + * merge doesn't waste too much area — specifically, whenever the merged + * rect's area is no more than 2x the sum of the two input areas, or the + * rects already overlap/touch. This repeats to a fixed point. If more than + * `maxRegions` rects remain, it falls back to merging everything into a + * single bounding rect (merge-all fallback) — simpler than picking which + * regions to keep, and cheap dirty-rect accounting favors correctness + * (never under-repaint) over minimizing painted area in the pathological + * case. + */ +export const mergeDirtyRects = (rects: Rect[], maxRegions = 4): Rect[] => { + const nonEmpty = rects.filter((r) => !isEmpty(r)); + if (nonEmpty.length <= 1) { + return nonEmpty; + } + + let current = nonEmpty.slice(); + const avgDimension = current.reduce((sum, r) => sum + (r.width + r.height) / 2, 0) / current.length; + const slack = avgDimension * 0.1; + + const findMergeablePair = (rectList: Rect[]): [number, number] | null => { + for (let i = 0; i < rectList.length; i++) { + for (let j = i + 1; j < rectList.length; j++) { + const a = rectList[i]; + const b = rectList[j]; + if (!a || !b) { + continue; + } + const overlaps = intersect(a, b) !== null; + const merged = union(a, b); + const wasteOk = area(merged) <= (area(a) + area(b)) * 2; + if (overlaps || (isNearby(a, b, slack) && wasteOk)) { + return [i, j]; + } + } + } + return null; + }; + + let pair = findMergeablePair(current); + while (pair !== null && current.length > 1) { + const [i, j] = pair; + const a = current[i] as Rect; + const b = current[j] as Rect; + const next = current.filter((_, idx) => idx !== i && idx !== j); + next.push(union(a, b)); + current = next; + pair = findMergeablePair(current); + } + + if (current.length > maxRegions) { + return [current.reduce((acc, r) => union(acc, r))]; + } + + return current; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/math/snapping.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/snapping.test.ts new file mode 100644 index 00000000000..174b70d7e50 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/snapping.test.ts @@ -0,0 +1,141 @@ +import type { Rect } from '@workbench/canvas-engine/types'; + +import { describe, expect, it } from 'vitest'; + +import { + clampZoom, + constrainAspect, + snapRectToGrid, + snapToGrid, + snapZoom, + ZOOM_MAX, + ZOOM_MIN, + ZOOM_SNAP_CANDIDATES, +} from './snapping'; + +describe('snapZoom', () => { + it('snaps to an exact candidate', () => { + expect(snapZoom(1)).toBe(1); + expect(snapZoom(0.25)).toBe(0.25); + }); + + it('snaps a value within tolerance of a candidate', () => { + expect(snapZoom(1.02)).toBe(1); + expect(snapZoom(0.98)).toBe(1); + }); + + it('passes through a value outside tolerance of any candidate', () => { + expect(snapZoom(1.2)).toBe(1.2); + expect(snapZoom(0.6)).toBe(0.6); + }); + + it('snaps at the tolerance boundary and passes through just beyond it', () => { + const candidate = 2; + const tolerance = candidate * 0.03; + expect(snapZoom(candidate + tolerance * 0.99)).toBe(candidate); + expect(snapZoom(candidate + tolerance * 1.5)).toBeCloseTo(candidate + tolerance * 1.5, 10); + }); + + it('supports custom candidates', () => { + expect(snapZoom(1.01, [1, 10])).toBe(1); + expect(snapZoom(5, [1, 10])).toBe(5); + }); + + it('exports the default candidates as a stable list', () => { + expect(ZOOM_SNAP_CANDIDATES).toEqual([0.25, 0.33, 0.5, 0.67, 0.75, 1, 1.25, 1.5, 2, 3, 4, 5]); + }); +}); + +describe('clampZoom', () => { + it('clamps values below the minimum', () => { + expect(clampZoom(0)).toBe(ZOOM_MIN); + expect(clampZoom(-5)).toBe(ZOOM_MIN); + }); + + it('clamps values above the maximum', () => { + expect(clampZoom(100)).toBe(ZOOM_MAX); + }); + + it('passes through in-range values', () => { + expect(clampZoom(1)).toBe(1); + expect(clampZoom(ZOOM_MIN)).toBe(ZOOM_MIN); + expect(clampZoom(ZOOM_MAX)).toBe(ZOOM_MAX); + }); +}); + +describe('snapToGrid', () => { + it('rounds to the nearest multiple of grid', () => { + expect(snapToGrid(10, 8)).toBe(8); + expect(snapToGrid(13, 8)).toBe(16); + expect(snapToGrid(0, 16)).toBe(0); + }); + + it('handles negative values', () => { + expect(snapToGrid(-10, 8)).toBe(-8); + }); + + it('returns the value unchanged for a non-positive grid', () => { + expect(snapToGrid(13.3, 0)).toBe(13.3); + expect(snapToGrid(13.3, -4)).toBe(13.3); + }); +}); + +describe('snapRectToGrid', () => { + it('snaps position and the far edge to the grid, deriving size', () => { + const r: Rect = { x: 3, y: 5, width: 30, height: 30 }; + // x: 3 -> 0, right: 33 -> 32 -> width 32 + // y: 5 -> 8, bottom: 35 -> 32 -> height 24 + expect(snapRectToGrid(r, 8)).toEqual({ x: 0, y: 8, width: 32, height: 24 }); + }); + + it('is a no-op for a rect already aligned to the grid', () => { + const r: Rect = { x: 16, y: 32, width: 64, height: 16 }; + expect(snapRectToGrid(r, 16)).toEqual(r); + }); +}); + +describe('constrainAspect', () => { + const square: Rect = { x: 10, y: 10, width: 20, height: 20 }; + + it('keeps the nw corner fixed', () => { + const result = constrainAspect(square, 2, 'nw'); + expect(result).toEqual({ x: 10, y: 10, width: 40, height: 20 }); + }); + + it('keeps the ne corner fixed', () => { + const result = constrainAspect(square, 2, 'ne'); + // right edge (30) stays fixed, width grows to 40, so x = 30 - 40 = -10 + expect(result).toEqual({ x: -10, y: 10, width: 40, height: 20 }); + }); + + it('keeps the sw corner fixed', () => { + const result = constrainAspect(square, 0.5, 'sw'); + // bottom edge (30) stays fixed; height = width / aspect... width kept? currentAspect(1) < aspect? no aspect=0.5 <1 + // currentAspect(1) > aspect(0.5) -> keep width(20), height = width/aspect = 40 + expect(result).toEqual({ x: 10, y: -10, width: 20, height: 40 }); + }); + + it('keeps the se corner fixed', () => { + const result = constrainAspect(square, 2, 'se'); + expect(result).toEqual({ x: -10, y: 10, width: 40, height: 20 }); + }); + + it('keeps the center fixed', () => { + const result = constrainAspect(square, 2, 'center'); + expect(result).toEqual({ x: 0, y: 10, width: 40, height: 20 }); + }); + + it('is a no-op-ish passthrough for a non-positive aspect or empty rect', () => { + expect(constrainAspect(square, 0, 'nw')).toEqual(square); + expect(constrainAspect(square, -1, 'nw')).toEqual(square); + const empty: Rect = { x: 0, y: 0, width: 0, height: 10 }; + expect(constrainAspect(empty, 2, 'nw')).toEqual(empty); + }); + + it('leaves an already-matching aspect rect unchanged in size', () => { + const wide: Rect = { x: 0, y: 0, width: 40, height: 20 }; + const result = constrainAspect(wide, 2, 'center'); + expect(result.width).toBeCloseTo(40, 8); + expect(result.height).toBeCloseTo(20, 8); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/math/snapping.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/snapping.ts new file mode 100644 index 00000000000..70c849277f7 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/snapping.ts @@ -0,0 +1,110 @@ +/** + * Pure snapping/clamping helpers for zoom, grid, and aspect-ratio math. + * + * No classes, no mutation — every function returns a new value. + */ + +import type { Rect } from '@workbench/canvas-engine/types'; + +/** Zoom levels the viewport snaps to when close enough. Single source of truth for the HUD. */ +export const ZOOM_SNAP_CANDIDATES: readonly number[] = [0.25, 0.33, 0.5, 0.67, 0.75, 1, 1.25, 1.5, 2, 3, 4, 5]; + +/** Relative tolerance (fraction of the candidate value) used by `snapZoom`. */ +export const ZOOM_SNAP_TOLERANCE = 0.03; + +/** Minimum allowed zoom, used by `clampZoom`. */ +export const ZOOM_MIN = 0.1; + +/** Maximum allowed zoom, used by `clampZoom`. */ +export const ZOOM_MAX = 20; + +/** + * Snaps `zoom` to the nearest of `candidates` if it's within a ~3% relative + * tolerance of that candidate; otherwise returns `zoom` unchanged. + */ +export const snapZoom = (zoom: number, candidates: readonly number[] = ZOOM_SNAP_CANDIDATES): number => { + let closest: number | null = null; + let closestDelta = Infinity; + for (const candidate of candidates) { + const delta = Math.abs(zoom - candidate); + if (delta < closestDelta) { + closestDelta = delta; + closest = candidate; + } + } + if (closest === null) { + return zoom; + } + const tolerance = closest * ZOOM_SNAP_TOLERANCE; + return closestDelta <= tolerance ? closest : zoom; +}; + +/** Clamps `zoom` to `[ZOOM_MIN, ZOOM_MAX]`. */ +export const clampZoom = (zoom: number): number => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom)); + +/** Snaps a single value to the nearest multiple of `grid`. Returns `value` unchanged if `grid <= 0`. */ +export const snapToGrid = (value: number, grid: number): number => { + if (grid <= 0) { + return value; + } + return Math.round(value / grid) * grid; +}; + +/** Snaps a rect's position and size to the nearest multiple of `grid`. */ +export const snapRectToGrid = (rect: Rect, grid: number): Rect => { + const x = snapToGrid(rect.x, grid); + const y = snapToGrid(rect.y, grid); + const right = snapToGrid(rect.x + rect.width, grid); + const bottom = snapToGrid(rect.y + rect.height, grid); + return { x, y, width: right - x, height: bottom - y }; +}; + +/** The corner or center kept fixed when `constrainAspect` resizes a rect. */ +export type AspectAnchor = 'nw' | 'ne' | 'sw' | 'se' | 'center'; + +/** + * Resizes `rect` to match `aspect` (width / height), keeping the named + * anchor point fixed. The rect's area is preserved as closely as possible + * by scaling both dimensions from the current size to fit the target + * aspect ratio (the dimension that would grow beyond the current bounding + * box is chosen based on which axis needs less change). + */ +export const constrainAspect = (rect: Rect, aspect: number, anchor: AspectAnchor): Rect => { + if (aspect <= 0 || rect.width <= 0 || rect.height <= 0) { + return rect; + } + + const currentAspect = rect.width / rect.height; + let width: number; + let height: number; + + if (currentAspect > aspect) { + // Too wide for the target aspect: keep width, shrink/grow height to match. + width = rect.width; + height = width / aspect; + } else { + // Too tall (or exact): keep height, shrink/grow width to match. + height = rect.height; + width = height * aspect; + } + + const left = rect.x; + const top = rect.y; + const right = rect.x + rect.width; + const bottom = rect.y + rect.height; + const centerX = rect.x + rect.width / 2; + const centerY = rect.y + rect.height / 2; + + switch (anchor) { + case 'nw': + return { x: left, y: top, width, height }; + case 'ne': + return { x: right - width, y: top, width, height }; + case 'sw': + return { x: left, y: bottom - height, width, height }; + case 'se': + return { x: right - width, y: bottom - height, width, height }; + case 'center': + return { x: centerX - width / 2, y: centerY - height / 2, width, height }; + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustedSurfaceCache.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustedSurfaceCache.test.ts new file mode 100644 index 00000000000..f06df528437 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustedSurfaceCache.test.ts @@ -0,0 +1,105 @@ +import type { CanvasAdjustmentsContract } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import type { LayerCacheEntry } from './layerCache'; +import type { StubRasterSurface } from './raster.testStub'; + +import { createAdjustedSurfaceCache } from './adjustedSurfaceCache'; +import { createTestStubRasterBackend } from './raster.testStub'; + +const BRIGHTEN: CanvasAdjustmentsContract = { brightness: 0.5, contrast: 0, saturation: 0 }; +const CONTRAST: CanvasAdjustmentsContract = { brightness: 0, contrast: 0.5, saturation: 0 }; + +const makeEntry = (layerId: string, surface: StubRasterSurface, version = 0): LayerCacheEntry => ({ + hasPublishedPixels: true, + lastUsed: 0, + layerId, + rect: { height: surface.height, width: surface.width, x: 0, y: 0 }, + stale: false, + surface, + version, +}); + +const drawImageCount = (surface: StubRasterSurface): number => + surface.callLog.filter((entry) => entry.op === 'drawImage').length; + +describe('createAdjustedSurfaceCache', () => { + it('returns null (and caches nothing) for identity adjustments', () => { + const backend = createTestStubRasterBackend(); + const cache = createAdjustedSurfaceCache(backend); + const entry = makeEntry('a', backend.createSurface(10, 10)); + expect(cache.get('a', entry, undefined)).toBeNull(); + expect(cache.get('a', entry, { brightness: 0, contrast: 0, saturation: 0 })).toBeNull(); + expect(cache.size()).toBe(0); + }); + + it('builds and reuses the adjusted surface while version + adjustments are unchanged', () => { + const backend = createTestStubRasterBackend(); + const cache = createAdjustedSurfaceCache(backend); + const entry = makeEntry('a', backend.createSurface(10, 10)); + + const first = cache.get('a', entry, BRIGHTEN); + expect(first).not.toBeNull(); + const stub = first as StubRasterSurface; + expect(drawImageCount(stub)).toBe(1); + + // Same version + key: reuse the same surface, no rebuild. + const second = cache.get('a', entry, BRIGHTEN); + expect(second).toBe(first); + expect(drawImageCount(stub)).toBe(1); + expect(cache.size()).toBe(1); + expect(cache.byteSize()).toBe(10 * 10 * 4); + }); + + it('rebuilds when the adjustments value changes', () => { + const backend = createTestStubRasterBackend(); + const cache = createAdjustedSurfaceCache(backend); + const entry = makeEntry('a', backend.createSurface(10, 10)); + + const first = cache.get('a', entry, BRIGHTEN) as StubRasterSurface; + expect(drawImageCount(first)).toBe(1); + // Different adjustments → rebuild (draws again into the reused surface). + cache.get('a', entry, CONTRAST); + expect(drawImageCount(first)).toBe(2); + }); + + it('invalidates only the edited layer when its cache version bumps', () => { + const backend = createTestStubRasterBackend(); + const cache = createAdjustedSurfaceCache(backend); + const entryA = makeEntry('a', backend.createSurface(10, 10), 0); + const entryB = makeEntry('b', backend.createSurface(10, 10), 0); + + const surfaceA = cache.get('a', entryA, BRIGHTEN) as StubRasterSurface; + const surfaceB = cache.get('b', entryB, BRIGHTEN) as StubRasterSurface; + expect(drawImageCount(surfaceA)).toBe(1); + expect(drawImageCount(surfaceB)).toBe(1); + + // Layer 'a' is edited: its cache version bumps → its adjusted surface rebuilds. + const editedA = makeEntry('a', surfaceA, 1); + const rebuiltA = cache.get('a', editedA, BRIGHTEN) as StubRasterSurface; + expect(rebuiltA).not.toBe(surfaceA); + expect(drawImageCount(rebuiltA)).toBe(1); + + // Layer 'b' is untouched (same version) → reused, no rebuild. + const reusedB = cache.get('b', entryB, BRIGHTEN); + expect(reusedB).toBe(surfaceB); + expect(drawImageCount(surfaceB)).toBe(1); + }); + + it('drops a layer slot on delete and reverting to identity', () => { + const backend = createTestStubRasterBackend(); + const cache = createAdjustedSurfaceCache(backend); + const entry = makeEntry('a', backend.createSurface(10, 10)); + cache.get('a', entry, BRIGHTEN); + expect(cache.size()).toBe(1); + // Reverting to identity clears the slot. + expect(cache.get('a', entry, undefined)).toBeNull(); + expect(cache.size()).toBe(0); + // Re-cache then delete. + cache.get('a', entry, BRIGHTEN); + cache.delete('a'); + expect(cache.size()).toBe(0); + expect(cache.byteSize()).toBe(0); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustedSurfaceCache.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustedSurfaceCache.ts new file mode 100644 index 00000000000..176e5c51cca --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustedSurfaceCache.ts @@ -0,0 +1,105 @@ +/** + * A cached, non-destructive "adjusted surface" per raster layer. + * + * Raster adjustments (brightness/contrast/saturation + curves) must be applied at + * composite time WITHOUT recomputing every frame (the plan explicitly forbids a + * third per-frame recompute alongside the mask-colorize and control-transparency + * smells). This store memoizes each layer's adjusted pixels, keyed by the source + * cache's `version` AND the adjustments' identity ({@link adjustmentsKey}), and + * rebuilds only when either changes — so a steady frame reuses the cached surface, + * an edit to the layer (cache version bump) invalidates it, and an unrelated + * layer's edit does not. + * + * Everything flows through the injected {@link RasterBackend} seam, so it runs + * unchanged in node tests. Zero React, zero import-time side effects. + */ + +import type { CanvasAdjustmentsContract } from '@workbench/types'; + +import type { LayerCacheEntry } from './layerCache'; +import type { RasterBackend, RasterSurface } from './raster'; + +import { adjustmentsKey, applyAdjustments, isIdentityAdjustments } from './adjustments'; +import { createDerivedSurfaceCache, type DerivedSurfaceCache } from './derivedSurfaceCache'; + +/** The imperative adjusted-surface store returned by {@link createAdjustedSurfaceCache}. */ +export interface AdjustedSurfaceCache { + /** + * Returns a surface holding `entry`'s pixels with `adjustments` applied, or + * `null` when the adjustments are identity (the caller should draw the original + * cache surface). The returned surface is memoized: an unchanged + * `(entry.version, adjustmentsKey)` reuses it with zero pixel work. + */ + get( + layerId: string, + entry: LayerCacheEntry, + adjustments: CanvasAdjustmentsContract | undefined + ): RasterSurface | null; + /** Drops a layer's memoized adjusted surface (e.g. on layer delete). */ + delete(layerId: string): void; + /** Number of memoized adjusted surfaces (for tests / accounting). */ + size(): number; + /** Bytes held by adjusted surfaces. */ + byteSize(): number; + /** Releases all memoized surfaces. */ + dispose(): void; +} + +/** Creates an {@link AdjustedSurfaceCache} backed by the given {@link RasterBackend}. */ +export const createAdjustedSurfaceCache = ( + backend: RasterBackend, + cache: DerivedSurfaceCache = createDerivedSurfaceCache() +): AdjustedSurfaceCache => { + const get = ( + layerId: string, + entry: LayerCacheEntry, + adjustments: CanvasAdjustmentsContract | undefined + ): RasterSurface | null => { + if (isIdentityAdjustments(adjustments)) { + // Identity: nothing to cache — drop any stale slot and let the caller draw + // the original surface. + cache.delete(layerId, 'adjustments'); + return null; + } + const { height, width } = entry.surface; + if (width <= 0 || height <= 0) { + return null; + } + const key = adjustmentsKey(adjustments); + return cache.get({ + create: (target) => { + const surface = target ?? backend.createSurface(width, height); + if (surface.width !== width || surface.height !== height) { + surface.resize(width, height); + } + const ctx = surface.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, width, height); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'source-over'; + ctx.drawImage(entry.surface.canvas, 0, 0); + const imageData = ctx.getImageData(0, 0, width, height); + applyAdjustments(imageData, adjustments); + ctx.putImageData(imageData, 0, 0); + return surface; + }, + kind: 'adjustments', + layerId, + paramsKey: key, + source: entry.surface, + sourceVersion: entry.version, + }); + }; + + return { + byteSize: cache.byteSize, + delete: (layerId) => { + cache.delete(layerId, 'adjustments'); + }, + dispose: () => { + cache.dispose(); + }, + get, + size: cache.size, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustments.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustments.test.ts new file mode 100644 index 00000000000..8fbe92e6b73 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustments.test.ts @@ -0,0 +1,211 @@ +import type { CanvasAdjustmentsContract } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import { + adjustmentsKey, + applyAdjustments, + buildAdjustmentLuts, + buildCurveLut, + DEFAULT_ADJUSTMENTS, + isIdentityAdjustments, +} from './adjustments'; + +/** Builds an ImageData-like object (node has no DOM ImageData; the shape is enough). */ +const imageData = (pixels: number[]): ImageData => + ({ data: new Uint8ClampedArray(pixels), height: 1, width: pixels.length / 4 }) as ImageData; + +describe('buildCurveLut', () => { + it('is the identity for absent / empty / diagonal curves', () => { + for (const pts of [ + undefined, + [], + [ + [0, 0], + [255, 255], + ], + ] as const) { + const lut = buildCurveLut(pts as never); + expect(lut[0]).toBe(0); + expect(lut[128]).toBe(128); + expect(lut[255]).toBe(255); + } + }); + + it('clamps values outside the first/last control point to the endpoints', () => { + // A curve that starts at x=64 (y=0) and ends at x=192 (y=255): below 64 → 0, above 192 → 255. + const lut = buildCurveLut([ + [64, 0], + [192, 255], + ]); + expect(lut[0]).toBe(0); + expect(lut[64]).toBe(0); + expect(lut[192]).toBe(255); + expect(lut[255]).toBe(255); + // Midpoint between the two knots is roughly mid grey. + expect(lut[128]).toBeGreaterThan(100); + expect(lut[128]).toBeLessThan(160); + }); + + it('interpolates monotonically through interior points without overshoot', () => { + const lut = buildCurveLut([ + [0, 0], + [128, 200], + [255, 255], + ]); + // The 128 knot is honoured. + expect(lut[128]).toBe(200); + // Monotonic non-decreasing, always within [0, 255]. + for (let i = 1; i < 256; i++) { + expect(lut[i]).toBeGreaterThanOrEqual(lut[i - 1]); + expect(lut[i]).toBeLessThanOrEqual(255); + expect(lut[i]).toBeGreaterThanOrEqual(0); + } + }); +}); + +describe('buildAdjustmentLuts', () => { + it('is the identity for default adjustments', () => { + const { b, g, r } = buildAdjustmentLuts(DEFAULT_ADJUSTMENTS); + for (let i = 0; i < 256; i++) { + expect(r[i]).toBe(i); + expect(g[i]).toBe(i); + expect(b[i]).toBe(i); + } + }); + + it('applies additive brightness and clamps', () => { + const { r } = buildAdjustmentLuts({ brightness: 0.5, contrast: 0, saturation: 0 }); + // +0.5*255 ≈ +128 (rounded). + expect(r[0]).toBe(128); + expect(r[200]).toBe(255); // clamped + }); + + it('applies contrast about mid-grey', () => { + const { r } = buildAdjustmentLuts({ brightness: 0, contrast: 1, saturation: 0 }); + // factor 2 about 128: 128 stays, 0 → -128 clamp 0, 255 → 382 clamp 255. + expect(r[128]).toBe(128); + expect(r[0]).toBe(0); + expect(r[255]).toBe(255); + expect(r[64]).toBe(0); // (64-128)*2+128 = 0 + expect(r[192]).toBe(255); // (192-128)*2+128 = 256 → clamp + }); + + it('composes curve → brightness → contrast', () => { + // A curve mapping everything to 100, then +0 brightness, contrast 0 → all 100. + const { r } = buildAdjustmentLuts({ + brightness: 0, + contrast: 0, + curves: { + b: [ + [0, 0], + [255, 255], + ], + g: [ + [0, 0], + [255, 255], + ], + r: [ + [0, 100], + [255, 100], + ], + }, + saturation: 0, + }); + expect(r[0]).toBe(100); + expect(r[255]).toBe(100); + }); +}); + +describe('isIdentityAdjustments / adjustmentsKey', () => { + it('treats zeros + diagonal / absent curves as identity', () => { + expect(isIdentityAdjustments(undefined)).toBe(true); + expect(isIdentityAdjustments(DEFAULT_ADJUSTMENTS)).toBe(true); + expect( + isIdentityAdjustments({ + brightness: 0, + contrast: 0, + saturation: 0, + curves: { + b: [ + [0, 0], + [255, 255], + ], + g: [ + [0, 0], + [255, 255], + ], + r: [ + [0, 0], + [255, 255], + ], + }, + }) + ).toBe(true); + expect(adjustmentsKey(DEFAULT_ADJUSTMENTS)).toBe('identity'); + }); + + it('is non-identity for any non-zero param or bent curve', () => { + expect(isIdentityAdjustments({ brightness: 0.1, contrast: 0, saturation: 0 })).toBe(false); + expect( + isIdentityAdjustments({ + brightness: 0, + contrast: 0, + saturation: 0, + curves: { + b: [ + [0, 0], + [255, 255], + ], + g: [ + [0, 0], + [255, 255], + ], + r: [ + [0, 0], + [128, 200], + [255, 255], + ], + }, + }) + ).toBe(false); + }); + + it('produces distinct, stable keys per distinct adjustment', () => { + const a: CanvasAdjustmentsContract = { brightness: 0.2, contrast: 0, saturation: 0 }; + const b: CanvasAdjustmentsContract = { brightness: 0.3, contrast: 0, saturation: 0 }; + expect(adjustmentsKey(a)).toBe(adjustmentsKey({ ...a })); + expect(adjustmentsKey(a)).not.toBe(adjustmentsKey(b)); + }); +}); + +describe('applyAdjustments', () => { + it('is a no-op for identity adjustments', () => { + const img = imageData([10, 20, 30, 255]); + applyAdjustments(img, DEFAULT_ADJUSTMENTS); + expect(Array.from(img.data)).toEqual([10, 20, 30, 255]); + }); + + it('never modifies alpha', () => { + const img = imageData([10, 20, 30, 128]); + applyAdjustments(img, { brightness: 0.5, contrast: 0, saturation: 0 }); + expect(img.data[3]).toBe(128); + }); + + it('brightens rgb', () => { + const img = imageData([10, 20, 30, 255]); + applyAdjustments(img, { brightness: 0.5, contrast: 0, saturation: 0 }); + expect(img.data[0]).toBe(138); // 10 + 128 + expect(img.data[1]).toBe(148); + expect(img.data[2]).toBe(158); + }); + + it('fully desaturates to luma at saturation -1', () => { + const img = imageData([200, 100, 50, 255]); + applyAdjustments(img, { brightness: 0, contrast: 0, saturation: -1 }); + const luma = Math.round(0.299 * 200 + 0.587 * 100 + 0.114 * 50); + expect(img.data[0]).toBe(luma); + expect(img.data[1]).toBe(luma); + expect(img.data[2]).toBe(luma); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustments.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustments.ts new file mode 100644 index 00000000000..9548f80b41c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustments.ts @@ -0,0 +1,239 @@ +/** + * Pure raster-adjustment math (no DOM, no engine, no React). + * + * Turns a {@link CanvasAdjustmentsContract} into per-channel 256-entry lookup + * tables and applies them (plus saturation) to raw `ImageData`. Kept pure and + * allocation-conscious so it can drive BOTH the non-destructive display cache + * (`adjustedSurfaceCache.ts`) and the generation composite + * (`export/compositeForGeneration.ts`), and so its correctness is exhaustively + * node-testable without any canvas. + * + * ## Composition order (documented, tested) + * + * Per channel, a single LUT is built as `contrast(brightness(curve(i)))`: + * 1. **Curve** — the per-channel monotone-cubic curve remaps the raw value. + * 2. **Brightness** — additive offset (`+ brightness * 255`). + * 3. **Contrast** — linear scale about mid-grey (`(v - 128) * (1 + contrast) + 128`). + * The LUT is applied to R/G/B; alpha is never touched. **Saturation** is applied + * last, per pixel, on the post-LUT values (a luminance lerp), since it mixes + * channels and cannot be expressed as an independent per-channel LUT. + * + * All scalar params are in `[-1, 1]` with `0` = identity; curve control points + * are `[input, output]` pairs in `[0, 255]`. + */ + +import type { CanvasAdjustmentsContract } from '@workbench/types'; + +/** The identity adjustment (no brightness/contrast/saturation, no curves). */ +export const DEFAULT_ADJUSTMENTS: CanvasAdjustmentsContract = { brightness: 0, contrast: 0, saturation: 0 }; + +const LUT_SIZE = 256; + +/** ITU-R BT.601 luma weights, matching legacy grayscale/lightness math. */ +const LUMA_R = 0.299; +const LUMA_G = 0.587; +const LUMA_B = 0.114; + +const clamp255 = (v: number): number => (v < 0 ? 0 : v > 255 ? 255 : v); + +type CurvePoints = readonly (readonly [number, number])[]; + +/** True when a channel's curve points describe the identity mapping (absent, or exactly 0→0 / 255→255). */ +const isIdentityCurve = (points: CurvePoints | undefined): boolean => { + if (!points || points.length === 0) { + return true; + } + // Any 2-point curve that is exactly the diagonal is identity. + if (points.length === 2) { + const sorted = [...points].sort((a, b) => a[0] - b[0]); + const [p0, p1] = sorted; + return p0[0] === 0 && p0[1] === 0 && p1[0] === 255 && p1[1] === 255; + } + return false; +}; + +/** + * Builds a 256-entry LUT that maps input → output through the channel's curve + * control points using monotone-cubic (Fritsch–Carlson) interpolation, so the + * result never overshoots between points. Fewer than two points → identity. + * Values before the first / after the last point are clamped to that point's + * output (flat extension). + */ +export const buildCurveLut = (points: CurvePoints | undefined): Uint8ClampedArray => { + const lut = new Uint8ClampedArray(LUT_SIZE); + if (isIdentityCurve(points)) { + for (let i = 0; i < LUT_SIZE; i++) { + lut[i] = i; + } + return lut; + } + + // Clean + sort + dedupe by x (keep the last y for a duplicated x). + const byX = new Map(); + for (const [x, y] of points as CurvePoints) { + byX.set(clamp255(Math.round(x)), clamp255(y)); + } + const xs = [...byX.keys()].sort((a, b) => a - b); + if (xs.length < 2) { + for (let i = 0; i < LUT_SIZE; i++) { + lut[i] = i; + } + return lut; + } + const ys = xs.map((x) => byX.get(x) as number); + + const n = xs.length; + // Secant slopes between consecutive points. + const delta: number[] = []; + for (let i = 0; i < n - 1; i++) { + const dx = xs[i + 1] - xs[i]; + delta.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx); + } + // Fritsch–Carlson tangents (m) enforcing monotonicity. + const m: number[] = Array.from({ length: n }, () => 0); + m[0] = delta[0]; + m[n - 1] = delta[n - 2]; + for (let i = 1; i < n - 1; i++) { + if (delta[i - 1] * delta[i] <= 0) { + m[i] = 0; + } else { + m[i] = (delta[i - 1] + delta[i]) / 2; + } + } + for (let i = 0; i < n - 1; i++) { + if (delta[i] === 0) { + m[i] = 0; + m[i + 1] = 0; + continue; + } + const a = m[i] / delta[i]; + const b = m[i + 1] / delta[i]; + const h = Math.hypot(a, b); + if (h > 3) { + const t = 3 / h; + m[i] = t * a * delta[i]; + m[i + 1] = t * b * delta[i]; + } + } + + let seg = 0; + for (let i = 0; i < LUT_SIZE; i++) { + if (i <= xs[0]) { + lut[i] = ys[0]; + continue; + } + if (i >= xs[n - 1]) { + lut[i] = ys[n - 1]; + continue; + } + while (seg < n - 2 && i > xs[seg + 1]) { + seg += 1; + } + const x0 = xs[seg]; + const x1 = xs[seg + 1]; + const hSeg = x1 - x0; + const t = (i - x0) / hSeg; + const t2 = t * t; + const t3 = t2 * t; + const h00 = 2 * t3 - 3 * t2 + 1; + const h10 = t3 - 2 * t2 + t; + const h01 = -2 * t3 + 3 * t2; + const h11 = t3 - t2; + const value = h00 * ys[seg] + h10 * hSeg * m[seg] + h01 * ys[seg + 1] + h11 * hSeg * m[seg + 1]; + lut[i] = clamp255(Math.round(value)); + } + return lut; +}; + +/** + * Builds the composed per-channel LUTs for `adjustments`: + * `contrast(brightness(curve(i)))`, clamped to `[0, 255]`. Brightness/contrast are + * shared across channels; the curve is per-channel. + */ +export const buildAdjustmentLuts = ( + adjustments: CanvasAdjustmentsContract +): { r: Uint8ClampedArray; g: Uint8ClampedArray; b: Uint8ClampedArray } => { + const brightnessOffset = (adjustments.brightness ?? 0) * 255; + const contrastFactor = 1 + (adjustments.contrast ?? 0); + const curves = adjustments.curves; + + const compose = (curveLut: Uint8ClampedArray): Uint8ClampedArray => { + const out = new Uint8ClampedArray(LUT_SIZE); + for (let i = 0; i < LUT_SIZE; i++) { + const curved = curveLut[i]; + const brightened = curved + brightnessOffset; + const contrasted = (brightened - 128) * contrastFactor + 128; + out[i] = clamp255(Math.round(contrasted)); + } + return out; + }; + + return { + b: compose(buildCurveLut(curves?.b)), + g: compose(buildCurveLut(curves?.g)), + r: compose(buildCurveLut(curves?.r)), + }; +}; + +/** True when `adjustments` is a no-op (identity brightness/contrast/saturation + identity curves). */ +export const isIdentityAdjustments = (adjustments: CanvasAdjustmentsContract | undefined): boolean => { + if (!adjustments) { + return true; + } + if ((adjustments.brightness ?? 0) !== 0 || (adjustments.contrast ?? 0) !== 0 || (adjustments.saturation ?? 0) !== 0) { + return false; + } + const { curves } = adjustments; + if (!curves) { + return true; + } + return isIdentityCurve(curves.r) && isIdentityCurve(curves.g) && isIdentityCurve(curves.b); +}; + +/** A deterministic cache key fully identifying an adjustment's pixel effect. */ +export const adjustmentsKey = (adjustments: CanvasAdjustmentsContract | undefined): string => { + if (isIdentityAdjustments(adjustments)) { + return 'identity'; + } + const a = adjustments as CanvasAdjustmentsContract; + const curveKey = (pts: CurvePoints | undefined): string => + pts && pts.length > 0 ? pts.map(([x, y]) => `${x},${y}`).join(';') : '-'; + return [ + `b${a.brightness ?? 0}`, + `c${a.contrast ?? 0}`, + `s${a.saturation ?? 0}`, + `r:${curveKey(a.curves?.r)}`, + `g:${curveKey(a.curves?.g)}`, + `bl:${curveKey(a.curves?.b)}`, + ].join('|'); +}; + +/** + * Applies `adjustments` to `imageData` IN PLACE: the composed per-channel LUTs + * remap R/G/B, then saturation lerps each channel toward its luma. Alpha is never + * modified. A no-op for identity adjustments. + */ +export const applyAdjustments = (imageData: ImageData, adjustments: CanvasAdjustmentsContract | undefined): void => { + if (isIdentityAdjustments(adjustments)) { + return; + } + const a = adjustments as CanvasAdjustmentsContract; + const { b: lutB, g: lutG, r: lutR } = buildAdjustmentLuts(a); + const sat = 1 + (a.saturation ?? 0); + const applySaturation = sat !== 1; + const { data } = imageData; + for (let i = 0; i + 3 < data.length; i += 4) { + let r = lutR[data[i]]; + let g = lutG[data[i + 1]]; + let b = lutB[data[i + 2]]; + if (applySaturation) { + const lum = LUMA_R * r + LUMA_G * g + LUMA_B * b; + r = clamp255(Math.round(lum + (r - lum) * sat)); + g = clamp255(Math.round(lum + (g - lum) * sat)); + b = clamp255(Math.round(lum + (b - lum) * sat)); + } + data[i] = r; + data[i + 1] = g; + data[i + 2] = b; + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/colorSample.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/colorSample.test.ts new file mode 100644 index 00000000000..c2cd0871825 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/colorSample.test.ts @@ -0,0 +1,147 @@ +import type { CanvasDocumentContractV2, CanvasLayerContract, CanvasRasterLayerContractV2 } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import type { RasterBackend, RasterSurface } from './raster'; + +import { sampleDocumentColor } from './colorSample'; +import { createLayerCacheStore } from './layerCache'; + +/** A fake surface that records `drawImage`/`setTransform` calls, for asserting traversal order and translation math. */ +interface FakeSurface extends RasterSurface { + drawnCanvases: unknown[]; + transforms: number[][]; +} + +/** A {@link RasterBackend} test double that also exposes every surface it created, for assertions. */ +interface FixedPixelBackend extends RasterBackend { + __surfaces: FakeSurface[]; +} + +/** + * A minimal `RasterBackend` whose scratch surfaces report a single fixed pixel + * for every `getImageData` call — enough to test `sampleDocumentColor`'s + * bounds/alpha/traversal logic without modeling real canvas compositing. + */ +const createFixedPixelBackend = (pixel: readonly [number, number, number, number]): FixedPixelBackend => { + const createdSurfaces: FakeSurface[] = []; + + return { + createImageBitmap: () => Promise.resolve({} as ImageBitmap), + createSurface: (width: number, height: number): FakeSurface => { + const drawnCanvases: unknown[] = []; + const transforms: number[][] = []; + const canvas = { height, width } as unknown as OffscreenCanvas; + const ctx = { + clearRect: () => {}, + drawImage: (image: unknown) => drawnCanvases.push(image), + getImageData: () => ({ data: Uint8ClampedArray.from(pixel), height: 1, width: 1 }) as unknown as ImageData, + restore: () => {}, + save: () => {}, + setTransform: (...args: number[]) => transforms.push(args), + } as unknown as OffscreenCanvasRenderingContext2D; + const surface: FakeSurface = { + canvas, + ctx, + drawnCanvases, + height, + resize: () => {}, + transforms, + width, + }; + createdSurfaces.push(surface); + return surface; + }, + encodeSurface: () => Promise.resolve(new Blob()), + __surfaces: createdSurfaces, + }; +}; + +const rasterLayer = (id: string, overrides: Partial = {}): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...overrides, +}); + +const makeDoc = (layers: CanvasLayerContract[]): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId: null, + version: 2, + width: 100, +}); + +describe('sampleDocumentColor', () => { + it('returns null for a point outside the document bounds (never allocates a scratch surface)', () => { + const backend = createFixedPixelBackend([10, 20, 30, 255]); + const layers = createLayerCacheStore(backend); + const doc = makeDoc([]); + + expect(sampleDocumentColor(doc, layers, backend, { x: -1, y: 5 })).toBeNull(); + expect(sampleDocumentColor(doc, layers, backend, { x: 100, y: 5 })).toBeNull(); + expect(sampleDocumentColor(doc, layers, backend, { x: 5, y: -1 })).toBeNull(); + expect(sampleDocumentColor(doc, layers, backend, { x: 5, y: 100 })).toBeNull(); + expect(backend.__surfaces).toHaveLength(0); + }); + + it('returns null when the composited pixel is fully transparent', () => { + const backend = createFixedPixelBackend([10, 20, 30, 0]); + const layers = createLayerCacheStore(backend); + const doc = makeDoc([rasterLayer('a')]); + layers.getOrCreate('a', 100, 100); + + expect(sampleDocumentColor(doc, layers, backend, { x: 5, y: 5 })).toBeNull(); + }); + + it('returns the composited rgba when the sampled pixel has non-zero alpha', () => { + const backend = createFixedPixelBackend([10, 20, 30, 128]); + const layers = createLayerCacheStore(backend); + const doc = makeDoc([rasterLayer('a')]); + layers.getOrCreate('a', 100, 100); + + expect(sampleDocumentColor(doc, layers, backend, { x: 5, y: 5 })).toEqual({ a: 128, b: 30, g: 20, r: 10 }); + }); + + it('draws renderable layers bottom-to-top, skipping disabled and uncached layers', () => { + const backend = createFixedPixelBackend([1, 2, 3, 255]); + const layers = createLayerCacheStore(backend); + const topEntry = layers.getOrCreate('top', 100, 100); + const bottomEntry = layers.getOrCreate('bottom', 100, 100); + // 'disabled' and 'nocache' are deliberately excluded from compositing. + const doc = makeDoc([ + rasterLayer('top'), + rasterLayer('disabled', { isEnabled: false }), + rasterLayer('nocache'), + rasterLayer('bottom'), + ]); + + sampleDocumentColor(doc, layers, backend, { x: 5, y: 5 }); + + const scratch = backend.__surfaces.at(-1)!; + expect(scratch.drawnCanvases).toEqual([bottomEntry.surface.canvas, topEntry.surface.canvas]); + }); + + it('translates the view so the floored sample point lands at the scratch origin', () => { + const backend = createFixedPixelBackend([1, 2, 3, 255]); + const layers = createLayerCacheStore(backend); + layers.getOrCreate('a', 100, 100); + const doc = makeDoc([rasterLayer('a')]); + + sampleDocumentColor(doc, layers, backend, { x: 12.7, y: 34.2 }); + + const scratch = backend.__surfaces.at(-1)!; + // Identity layer transform composed with the sample-point translation: e/f + // carry the floored, negated point (no per-layer offset/scale/rotation). + // The last `setTransform` is the per-layer draw (the first is the initial reset). + expect(scratch.transforms.at(-1)).toEqual([1, 0, 0, 1, -12, -34]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/colorSample.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/colorSample.ts new file mode 100644 index 00000000000..e4a59d82d83 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/colorSample.ts @@ -0,0 +1,94 @@ +/** + * Samples the composited document color at a document-space point, for the + * color-picker tool. Composites just the renderable layers (bottom → top, + * respecting opacity/blend/transform, matching {@link compositeDocument}'s + * paint order) into a 1×1 scratch surface translated so the sampled pixel + * lands at its origin — cheap regardless of document size, since only one + * destination pixel is ever rasterized. + * + * The document background (solid fill or checkerboard) and the staged + * preview are intentionally excluded: a point with no layer coverage samples + * as fully transparent (`null`), not the page chrome. + * + * Zero React, zero import-time side effects. + */ + +import type { Mat2d, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { isRenderableLayer } from '@workbench/canvas-engine/document/sources'; +import { fromTRS, multiply } from '@workbench/canvas-engine/math/mat2d'; + +import type { LayerCacheStore } from './layerCache'; +import type { RasterBackend } from './raster'; + +import { blendToComposite } from './compositor'; + +/** An RGBA sample, channels in `[0, 255]`. */ +export interface RgbaSample { + r: number; + g: number; + b: number; + a: number; +} + +const matToTuple = (m: Mat2d): [number, number, number, number, number, number] => [m.a, m.b, m.c, m.d, m.e, m.f]; + +/** + * Samples the composited document color at `docPoint` (document space; + * fractional coordinates are floored to the covering pixel). Returns `null` + * when the point falls outside the document, or when no renderable layer + * covers it with non-zero alpha there. + */ +export const sampleDocumentColor = ( + doc: CanvasDocumentContractV2, + layers: LayerCacheStore, + backend: RasterBackend, + docPoint: Vec2 +): RgbaSample | null => { + const px = Math.floor(docPoint.x); + const py = Math.floor(docPoint.y); + if (px < 0 || py < 0 || px >= doc.width || py >= doc.height) { + return null; + } + + const scratch = backend.createSurface(1, 1); + const ctx = scratch.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, 1, 1); + // Translate so the sampled document pixel lands at the scratch surface's + // one pixel (0, 0); each layer's own transform composes on top of this. + const view: Mat2d = { a: 1, b: 0, c: 0, d: 1, e: -px, f: -py }; + + // Bottom → top, matching the real compositor's paint order. + for (let i = doc.layers.length - 1; i >= 0; i--) { + const layer = doc.layers[i]; + if (!layer || !isRenderableLayer(layer)) { + continue; + } + const entry = layers.get(layer.id); + if (!entry) { + continue; + } + + ctx.save(); + ctx.globalAlpha = layer.opacity; + ctx.globalCompositeOperation = blendToComposite(layer.blendMode); + const layerMat = fromTRS( + { x: layer.transform.x, y: layer.transform.y }, + layer.transform.rotation, + layer.transform.scaleX, + layer.transform.scaleY + ); + ctx.setTransform(...matToTuple(multiply(view, layerMat))); + ctx.drawImage(entry.surface.canvas, 0, 0); + ctx.restore(); + } + + const { data } = ctx.getImageData(0, 0, 1, 1); + const alpha = data[3] ?? 0; + if (alpha === 0) { + return null; + } + return { a: alpha, b: data[2] ?? 0, g: data[1] ?? 0, r: data[0] ?? 0 }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.test.ts new file mode 100644 index 00000000000..d7b7d0f41f8 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.test.ts @@ -0,0 +1,624 @@ +import type { Mat2d } from '@workbench/canvas-engine/types'; +import type { + CanvasBlendMode, + CanvasDocumentContractV2, + CanvasImageRef, + CanvasLayerContract, + CanvasRasterLayerContractV2, +} from '@workbench/types'; + +import { createCanvasDiagnostics } from '@workbench/canvas-engine/diagnostics'; +import { identity } from '@workbench/canvas-engine/math/mat2d'; +import { describe, expect, it } from 'vitest'; + +import type { RasterCallLogEntry, StubRasterSurface } from './raster.testStub'; + +import { compositeDocument, createCheckerboardTile, shouldSmoothAtZoom } from './compositor'; +import { createDerivedSurfaceCache } from './derivedSurfaceCache'; +import { createLayerCacheStore } from './layerCache'; +import { createTestStubRasterBackend } from './raster.testStub'; + +const VIEW: Mat2d = identity(); + +const imageRef = (): CanvasImageRef => ({ height: 10, imageName: 'x', width: 10 }); + +const rasterLayer = (id: string, overrides: Partial = {}): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: imageRef(), type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...overrides, +}); + +const maskLayer = (id: string): CanvasLayerContract => ({ + autoNegative: false, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#ff0000', style: 'solid' } }, + name: id, + negativePrompt: null, + opacity: 1, + positivePrompt: null, + referenceImages: [], + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'regional_guidance', +}); + +const controlLayer = (id: string): CanvasLayerContract => ({ + adapter: { beginEndStepPct: [0, 0.75], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: imageRef(), type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: false, +}); + +const inpaintMaskLayer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#00ff00', style: 'solid' } }, + name: id, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +const makeDoc = ( + layers: CanvasLayerContract[], + overrides: Partial = {} +): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId: null, + version: 2, + width: 100, + ...overrides, +}); + +/** Sets are recorded as { op: 'set', args: [prop, value] }; find the value for a prop. */ +const findSet = (log: RasterCallLogEntry[], prop: string): unknown[] => + log.filter((e) => e.op === 'set' && e.args[0] === prop).map((e) => e.args[1]); + +describe('compositeDocument', () => { + it('clears then draws layer caches bottom-to-top (array index 0 is top-most)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + // top -> bottom in array order. + const top = rasterLayer('top'); + const bottom = rasterLayer('bottom'); + const topCache = caches.getOrCreate('top', 10, 10); + const bottomCache = caches.getOrCreate('bottom', 10, 10); + + const target = backend.createSurface(200, 200); + compositeDocument(target, makeDoc([top, bottom]), caches, VIEW); + + const log = target.callLog; + // First op is a clearRect (after the outer save + identity setTransform). + expect(log.some((e) => e.op === 'clearRect')).toBe(true); + + const drawImages = log.filter((e) => e.op === 'drawImage'); + expect(drawImages).toHaveLength(2); + // Bottom layer's canvas is drawn before the top layer's. + expect(drawImages[0]!.args[0]).toBe(bottomCache.surface.canvas); + expect(drawImages[1]!.args[0]).toBe(topCache.surface.canvas); + }); + + it('skips layers that are disabled', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('a', 10, 10); + caches.getOrCreate('b', 10, 10); + const target = backend.createSurface(200, 200); + + const doc = makeDoc([rasterLayer('a', { isEnabled: false }), rasterLayer('b')]); + compositeDocument(target, doc, caches, VIEW); + + const drawImages = target.callLog.filter((e) => e.op === 'drawImage'); + expect(drawImages).toHaveLength(1); + expect(drawImages[0]!.args[0]).toBe(caches.get('b')!.surface.canvas); + }); + + it('isolates the named layer even when disabled and suppresses unrelated staged content', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + const isolated = caches.getOrCreate('isolated', 10, 10); + caches.getOrCreate('other', 10, 10); + const filterPreview = backend.createSurface(10, 10); + const staged = backend.createSurface(10, 10); + const target = backend.createSurface(200, 200); + + compositeDocument( + target, + makeDoc([rasterLayer('isolated', { isEnabled: false }), rasterLayer('other')]), + caches, + VIEW, + { + layerPreviews: new Map([['isolated', { rect: { height: 10, width: 10, x: 0, y: 0 }, surface: filterPreview }]]), + onlyLayerId: 'isolated', + stagedPreview: { rect: { height: 10, width: 10, x: 0, y: 0 }, surface: staged }, + } + ); + + const drawImages = target.callLog.filter((entry) => entry.op === 'drawImage'); + expect(drawImages).toHaveLength(1); + expect(drawImages[0]!.args[0]).toBe(isolated.surface.canvas); + }); + + it('preserves disabled-layer and staged-preview semantics when isolation is absent', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('disabled', 10, 10); + const visible = caches.getOrCreate('visible', 10, 10); + const staged = backend.createSurface(10, 10); + const target = backend.createSurface(200, 200); + + compositeDocument( + target, + makeDoc([rasterLayer('disabled', { isEnabled: false }), rasterLayer('visible')]), + caches, + VIEW, + { stagedPreview: { rect: { height: 10, width: 10, x: 0, y: 0 }, surface: staged } } + ); + + const drawImages = target.callLog.filter((entry) => entry.op === 'drawImage'); + expect(drawImages.map((entry) => entry.args[0])).toEqual([visible.surface.canvas, staged.canvas]); + }); + + it('skips the layer named by skipLayerId (open text-edit session)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('a', 10, 10); + caches.getOrCreate('b', 10, 10); + const target = backend.createSurface(200, 200); + + const doc = makeDoc([rasterLayer('a'), rasterLayer('b')]); + compositeDocument(target, doc, caches, VIEW, { skipLayerId: 'a' }); + + const drawImages = target.callLog.filter((e) => e.op === 'drawImage'); + // 'a' is skipped (its live text is shown by the contenteditable portal); only 'b' draws. + expect(drawImages).toHaveLength(1); + expect(drawImages[0]!.args[0]).toBe(caches.get('b')!.surface.canvas); + }); + + it('skips layers that have no cache entry yet', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('a', 10, 10); + const target = backend.createSurface(200, 200); + + // 'b' has no cache; only 'a' should draw. + compositeDocument(target, makeDoc([rasterLayer('a'), rasterLayer('b')]), caches, VIEW); + expect(target.callLog.filter((e) => e.op === 'drawImage')).toHaveLength(1); + }); + + it('applies per-layer opacity and maps blend mode to a composite operation', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('a', 10, 10); + const target = backend.createSurface(200, 200); + + const blend: CanvasBlendMode = 'multiply'; + compositeDocument(target, makeDoc([rasterLayer('a', { blendMode: blend, opacity: 0.4 })]), caches, VIEW); + + expect(findSet(target.callLog, 'globalAlpha')).toContain(0.4); + expect(findSet(target.callLog, 'globalCompositeOperation')).toContain('multiply'); + }); + + it('applies control transparency, opacity, and blend mode to a filter preview', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('control', 10, 10); + const preview = backend.createSurface(10, 10); + const target = backend.createSurface(100, 100); + const layer = { + ...controlLayer('control'), + blendMode: 'multiply' as const, + opacity: 0.4, + withTransparencyEffect: true, + }; + + compositeDocument(target, makeDoc([layer]), caches, VIEW, { + backend, + layerPreviews: new Map([['control', { rect: { height: 14, width: 16, x: -2, y: -3 }, surface: preview }]]), + }); + + const draw = target.callLog.find((entry) => entry.op === 'drawImage'); + expect(draw?.args[0]).not.toBe(preview.canvas); + expect(draw?.args.slice(1)).toEqual([-2, -3]); + expect(findSet(target.callLog, 'globalAlpha')).toContain(0.4); + expect(findSet(target.callLog, 'globalCompositeOperation')).toContain('multiply'); + }); + + it("maps the 'normal' blend mode to 'source-over'", () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('a', 10, 10); + const target = backend.createSurface(200, 200); + + compositeDocument(target, makeDoc([rasterLayer('a', { blendMode: 'normal' })]), caches, VIEW); + expect(findSet(target.callLog, 'globalCompositeOperation')).toContain('source-over'); + }); + + it('fills the ENTIRE viewport with the checkerboard pattern (unbounded plane)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + const tile = createCheckerboardTile(backend); + + const target = backend.createSurface(200, 200); + compositeDocument(target, makeDoc([], { background: 'transparent' }), caches, VIEW, { checkerboardTile: tile }); + + // The pattern is created once from the tile (cheap: no per-cell fill loop). + const patternCalls = target.callLog.filter((e) => e.op === 'createPattern'); + expect(patternCalls).toHaveLength(1); + expect(patternCalls[0]!.args[0]).toBe(tile.canvas); + expect(patternCalls[0]!.args[1]).toBe('repeat'); + + // The whole 200x200 target is filled with the pattern — NOT clipped to the + // 100x100 doc rect (the document is no longer a visual boundary). + const fills = target.callLog.filter((e) => e.op === 'fillRect'); + expect(fills).toHaveLength(1); + expect(fills[0]!.args).toEqual([0, 0, 200, 200]); + // fillStyle was set to the pattern object (the stub's non-null marker), not a color string. + const styles = findSet(target.callLog, 'fillStyle'); + expect(styles).toHaveLength(1); + expect(typeof styles[0]).toBe('object'); + }); + + it('fills the full viewport regardless of the doc rect position/size (offset + scaled view)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + const tile = createCheckerboardTile(backend); + + // A tiny doc, panned far off-origin under a scaled view: the checker still + // covers the whole screen because it is screen-anchored, not doc-anchored. + const view: Mat2d = { a: 3, b: 0, c: 0, d: 3, e: -500, f: 220 }; + const target = backend.createSurface(320, 240); + compositeDocument(target, makeDoc([], { height: 8, width: 8 }), caches, view, { checkerboardTile: tile }); + + const fills = target.callLog.filter((e) => e.op === 'fillRect'); + expect(fills).toHaveLength(1); + expect(fills[0]!.args).toEqual([0, 0, 320, 240]); + }); + + it('draws NO checkerboard when the tile is absent (toggle off), leaving bg.inset through', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + + const target = backend.createSurface(200, 200); + compositeDocument(target, makeDoc([], { background: 'transparent' }), caches, VIEW); + + // No tile → no pattern and no fill; the cleared surface shows the widget's bg.inset. + expect(target.callLog.some((e) => e.op === 'createPattern')).toBe(false); + expect(target.callLog.filter((e) => e.op === 'fillRect')).toHaveLength(0); + // The whole target is still cleared each frame. + expect(target.callLog.some((e) => e.op === 'clearRect')).toBe(true); + }); + + it('ignores the contract background field (checker fills the viewport even for a color background)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + + const solidTarget = backend.createSurface(200, 200); + // The `background` field no longer renders: a color-background doc with the + // checkerboard on still fills the whole viewport with the pattern, not a flat color. + compositeDocument(solidTarget, makeDoc([], { background: { color: '#123456' } }), caches, VIEW, { + checkerboardTile: createCheckerboardTile(backend), + }); + const patternCalls = solidTarget.callLog.filter((e) => e.op === 'createPattern'); + expect(patternCalls).toHaveLength(1); + const fills = solidTarget.callLog.filter((e) => e.op === 'fillRect'); + expect(fills).toHaveLength(1); + expect(fills[0]!.args).toEqual([0, 0, 200, 200]); + // The flat color is never applied. + expect(findSet(solidTarget.callLog, 'fillStyle')).not.toContain('#123456'); + }); + + it('builds the checker tile with the given colors on the diagonal', () => { + const backend = createTestStubRasterBackend(); + const tile = createCheckerboardTile(backend, { a: '#111111', b: '#222222' }) as StubRasterSurface; + // The tile's two fillStyle sets are exactly the fed colors (base then diagonal). + const styles = findSet(tile.callLog, 'fillStyle'); + expect(styles).toEqual(['#111111', '#222222']); + }); + + it('draws mask-bearing layers as a tinted fill of the mask color (backend-less fallback)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('rg', 10, 10); + const target = backend.createSurface(200, 200); + + compositeDocument(target, makeDoc([maskLayer('rg')]), caches, VIEW); + + // Without a backend the mask draws its coverage then tints with a flat fill. + expect(target.callLog.some((e) => e.op === 'drawImage')).toBe(true); + expect(findSet(target.callLog, 'fillStyle')).toContain('#ff0000'); + }); + + it('colorizes the mask alpha via source-in on an intermediate surface when a backend is supplied', () => { + const base = createTestStubRasterBackend(); + const created: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + created.push(surface); + return surface; + }, + }; + const caches = createLayerCacheStore(backend); + const maskEntry = caches.getOrCreate('rg', 10, 10) as unknown as { surface: StubRasterSurface }; + const target = backend.createSurface(200, 200); + + compositeDocument(target, makeDoc([maskLayer('rg')]), caches, VIEW, { backend }); + + // The colorize happens on a NEW intermediate surface (not the target, not the + // mask cache): it blits the stencil then fills source-in with the fill colour. + const colorized = created.find( + (s) => + s !== target && + s !== maskEntry.surface && + s.callLog.some((e) => e.op === 'set' && e.args[0] === 'globalCompositeOperation' && e.args[1] === 'source-in') + ); + expect(colorized).toBeDefined(); + expect(findSet(colorized!.callLog, 'fillStyle')).toContain('#ff0000'); + // The colorized overlay is then blitted onto the target. + expect(target.callLog.some((e) => e.op === 'drawImage')).toBe(true); + }); + + it('performs no effect allocations or pixel readbacks on a warmed unchanged composite', () => { + const base = createTestStubRasterBackend(); + const created: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + created.push(surface); + return surface; + }, + }; + const caches = createLayerCacheStore(backend); + caches.getOrCreate('control', 10, 10); + caches.getOrCreate('mask', 10, 10); + const target = backend.createSurface(100, 100); + const derivedSurfaces = createDerivedSurfaceCache(); + const control = controlLayer('control'); + if (control.type !== 'control') { + throw new Error('Expected control fixture'); + } + const doc = makeDoc([{ ...control, withTransparencyEffect: true }, maskLayer('mask')]); + + compositeDocument(target, doc, caches, VIEW, { backend, derivedSurfaces }); + const allocationsAfterWarmup = created.length; + const readbacksAfterWarmup = created.reduce( + (count, surface) => count + surface.callLog.filter((entry) => entry.op === 'getImageData').length, + 0 + ); + + compositeDocument(target, doc, caches, VIEW, { backend, derivedSurfaces }); + expect(created).toHaveLength(allocationsAfterWarmup); + expect( + created.reduce( + (count, surface) => count + surface.callLog.filter((entry) => entry.op === 'getImageData').length, + 0 + ) + ).toBe(readbacksAfterWarmup); + }); + + it('culls a fully offscreen effect layer before derived work or drawing', () => { + const base = createTestStubRasterBackend(); + let allocations = 0; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + allocations += 1; + return base.createSurface(w, h); + }, + }; + const caches = createLayerCacheStore(backend); + caches.getOrCreate('control', 10, 10); + const target = backend.createSurface(100, 100); + const control = controlLayer('control'); + if (control.type !== 'control') { + throw new Error('Expected control fixture'); + } + const doc = makeDoc([{ ...control, transform: { ...control.transform, x: 1_000 }, withTransparencyEffect: true }]); + const allocationsBeforeComposite = allocations; + const diagnostics = createCanvasDiagnostics(true); + + compositeDocument(target, doc, caches, VIEW, { + backend, + derivedSurfaces: createDerivedSurfaceCache(), + diagnostics, + }); + + expect(allocations).toBe(allocationsBeforeComposite); + expect(target.callLog.filter((entry) => entry.op === 'drawImage')).toHaveLength(0); + expect(diagnostics.snapshot()).toMatchObject({ + compositeFrames: 1, + layersConsidered: 1, + layersCulled: 1, + layersDrawn: 0, + }); + }); + + it('draws mask layers ABOVE all non-mask layers regardless of their global z position', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + // A mask placed at the BOTTOM of the z-order (last in the array) must still be + // composited after (above) the raster layer above it. + caches.getOrCreate('raster', 10, 10); + caches.getOrCreate('mask', 10, 10); + const target = backend.createSurface(200, 200) as StubRasterSurface; + + const doc = makeDoc([rasterLayer('raster'), maskLayer('mask')]); + compositeDocument(target, doc, caches, VIEW, { backend }); + + // The mask's source-in colorize marks its draw pass; it must come AFTER the + // last raster blit. Find the first source-in op (mask pass) and assert every + // non-mask blit already ran — i.e. the mask pass draws last. + const firstDrawImage = target.callLog.findIndex((e) => e.op === 'drawImage'); + expect(firstDrawImage).toBeGreaterThanOrEqual(0); + // Two draws land on the target: the raster blit, then the colorized mask blit; + // the mask blit is the LAST drawImage. + const drawIdxs = target.callLog.map((e, i) => (e.op === 'drawImage' ? i : -1)).filter((i) => i >= 0); + expect(drawIdxs.length).toBeGreaterThanOrEqual(2); + }); + + it('composites in strict group order: raster < control < regional < inpaint mask, ignoring global index', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + // Distinct cache sizes so each layer's blit is identifiable by source width + // (masks blit a colorized intermediate sized to their cache, not the cache + // surface itself, so identity matching won't work — width does). + caches.getOrCreate('raster', 10, 10); + caches.getOrCreate('control', 11, 11); + caches.getOrCreate('regional', 12, 12); + caches.getOrCreate('inpaint', 13, 13); + const widthToId: Record = { 10: 'raster', 11: 'control', 12: 'regional', 13: 'inpaint' }; + const target = backend.createSurface(200, 200) as StubRasterSurface; + + // Deliberately SCRAMBLED array order (index 0 = top-most): a raster created + // above a control layer, masks interleaved below. Group order must win. + const doc = makeDoc([ + rasterLayer('raster'), + inpaintMaskLayer('inpaint'), + controlLayer('control'), + maskLayer('regional'), + ]); + compositeDocument(target, doc, caches, VIEW, { backend }); + + const order = target.callLog + .filter((e) => e.op === 'drawImage') + .map((e) => widthToId[(e.args[0] as { width: number }).width]) + .filter((id): id is string => id !== undefined); + + // Raster (bottom) first, then control, then the masks — regardless of array index. + expect(order.indexOf('raster')).toBeLessThan(order.indexOf('control')); + expect(order.indexOf('control')).toBeLessThan(order.indexOf('regional')); + expect(order.indexOf('regional')).toBeLessThan(order.indexOf('inpaint')); + }); + + it('disables image smoothing for the composite when imageSmoothing is false (zoomed-in policy)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('a', 10, 10); + const target = backend.createSurface(200, 200); + + compositeDocument(target, makeDoc([rasterLayer('a')]), caches, VIEW, { imageSmoothing: false }); + // The smoothing flag is set exactly once, to false, so every layer/staged + // blit up-scales nearest-neighbor (crisp + cheap) rather than bilinear. + expect(findSet(target.callLog, 'imageSmoothingEnabled')).toEqual([false]); + }); + + it('enables image smoothing by default and when imageSmoothing is true (down-scale/quality)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('a', 10, 10); + + const defaulted = backend.createSurface(200, 200); + compositeDocument(defaulted, makeDoc([rasterLayer('a')]), caches, VIEW); + expect(findSet(defaulted.callLog, 'imageSmoothingEnabled')).toEqual([true]); + + const explicit = backend.createSurface(200, 200); + compositeDocument(explicit, makeDoc([rasterLayer('a')]), caches, VIEW, { imageSmoothing: true }); + expect(findSet(explicit.callLog, 'imageSmoothingEnabled')).toEqual([true]); + }); + + it('draws a staged preview over its bbox when provided', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + const target = backend.createSurface(200, 200); + const staged = backend.createSurface(50, 50); + + compositeDocument(target, makeDoc([]), caches, VIEW, { + stagedPreview: { rect: { height: 40, width: 40, x: 5, y: 5 }, surface: staged }, + }); + + const drawImages = target.callLog.filter((e) => e.op === 'drawImage'); + expect(drawImages).toHaveLength(1); + expect(drawImages[0]!.args).toEqual([staged.canvas, 5, 5, 40, 40]); + }); + + it('draws a placed staged preview at its candidate opacity and keeps the pending outline opaque', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + const target = backend.createSurface(200, 200); + const staged = backend.createSurface(23, 17); + + compositeDocument(target, makeDoc([]), caches, VIEW, { + stagedPreview: { + opacity: 0.35, + rect: { height: 34, width: 46, x: -8, y: 13 }, + surface: staged, + }, + }); + + const drawImages = target.callLog.filter((entry) => entry.op === 'drawImage'); + expect(drawImages).toHaveLength(1); + expect(drawImages[0]!.args).toEqual([staged.canvas, -8, 13, 46, 34]); + expect(findSet(target.callLog, 'globalAlpha')).toEqual([0.35, 1]); + }); +}); + +describe('compositeDocument — raster adjustments', () => { + it('draws the provided adjusted surface instead of the raw cache for a raster layer', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + const layer = rasterLayer('a', { adjustments: { brightness: 0.5, contrast: 0, saturation: 0 } }); + const cache = caches.getOrCreate('a', 10, 10); + const adjusted = backend.createSurface(10, 10); + const target = backend.createSurface(200, 200); + + compositeDocument(target, makeDoc([layer]), caches, VIEW, { + adjustedSurface: (l) => (l.id === 'a' ? adjusted : null), + }); + + const drawImages = target.callLog.filter((e) => e.op === 'drawImage'); + expect(drawImages).toHaveLength(1); + // The adjusted surface's canvas is drawn, NOT the raw cache surface. + expect(drawImages[0]!.args[0]).toBe(adjusted.canvas); + expect(drawImages[0]!.args[0]).not.toBe(cache.surface.canvas); + }); + + it('draws the raw cache when the provider returns null (identity / no adjustments)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + const layer = rasterLayer('a'); + const cache = caches.getOrCreate('a', 10, 10); + const target = backend.createSurface(200, 200); + + compositeDocument(target, makeDoc([layer]), caches, VIEW, { adjustedSurface: () => null }); + + const drawImages = target.callLog.filter((e) => e.op === 'drawImage'); + expect(drawImages[0]!.args[0]).toBe(cache.surface.canvas); + }); +}); + +describe('shouldSmoothAtZoom', () => { + it('smooths only when the document is down-scaled (zoom < 1)', () => { + expect(shouldSmoothAtZoom(0.1)).toBe(true); + expect(shouldSmoothAtZoom(0.5)).toBe(true); + expect(shouldSmoothAtZoom(0.99)).toBe(true); + // At or above 1× the document is magnified: keep pixels crisp and skip the + // per-frame bilinear up-scale whose cost grows with zoom. + expect(shouldSmoothAtZoom(1)).toBe(false); + expect(shouldSmoothAtZoom(4)).toBe(false); + expect(shouldSmoothAtZoom(20)).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.ts new file mode 100644 index 00000000000..d80aa00a6b2 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.ts @@ -0,0 +1,473 @@ +/** + * Composites a canvas document onto a target surface. + * + * Draws (in order): a clear + a viewport-filling checkerboard (the unbounded + * plane's surround; omitted when the checkerboard is off), then each layer's + * cached surface from bottom to top applying opacity / blend mode / transform, + * and finally an optional staged-generation preview. Layers without a cache entry are + * skipped — rasterization is the caller's job (see `rasterizers/` + + * `layerCache.ts`); this module only draws what's already cached. + * + * Every pixel operation flows through the {@link RasterSurface} `ctx`, which + * in tests is the recording stub, so composite order is assertable in node. + * Zero React, zero import-time side effects. + */ + +import type { CanvasDiagnostics } from '@workbench/canvas-engine/diagnostics'; +import type { Mat2d, Rect, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasBlendMode, CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { LAYER_GROUP_COUNT, layerGroupRank } from '@workbench/canvas-engine/document/sources'; +import { fromTRS, multiply } from '@workbench/canvas-engine/math/mat2d'; +import { intersect, transformBounds } from '@workbench/canvas-engine/math/rect'; + +import type { DerivedSurfaceCache } from './derivedSurfaceCache'; +import type { LayerCacheEntry, LayerCacheStore } from './layerCache'; +import type { RasterBackend, RasterSurface } from './raster'; + +import { renderControlTransparency } from './controlTransparency'; +import { colorizeMask } from './maskFill'; + +/** Screen-space size (px) of each checkerboard square for transparent backgrounds. */ +export const CHECKERBOARD_SQUARE_PX = 8; + +/** + * CSS-zoom threshold at/above which image smoothing is disabled while + * compositing. See {@link shouldSmoothAtZoom}. + */ +export const SMOOTHING_MAX_ZOOM = 1; + +/** + * Image-smoothing policy for compositing at a given CSS zoom. + * + * Smoothing is enabled only when the document is DOWN-scaled (`zoom < 1`) — + * bilinear interpolation keeps a shrunk image clean. When zoomed IN + * (`zoom >= 1`) the document is up-scaled to fill the screen; smoothing is + * disabled so (a) pixels stay crisp when magnified — the behavior legacy pixel + * editors adopt above ~1× — and (b) the browser skips the per-frame bilinear + * interpolation of an ever-larger upscale, whose fill-rate cost is precisely + * what grows with zoom. Nearest-neighbor upscaling is dramatically cheaper. + */ +export const shouldSmoothAtZoom = (zoom: number): boolean => zoom < SMOOTHING_MAX_ZOOM; + +/** The two square colors of the transparency checkerboard. */ +export interface CheckerColors { + /** The base color, filled across the whole tile. */ + a: string; + /** The alternating color, drawn on the tile's diagonal cells. */ + b: string; +} + +/** + * Fallback checkerboard colors when no theme tokens have been fed to the engine + * (node tests, first frame before React resolves the tokens). Deliberately DARK, + * theme-appropriate greys (in the spirit of the legacy dark transparency pattern) + * so the indicator reads as "empty" against the dark workbench surface. In the + * app these are replaced by resolved Chakra semantic tokens (see + * `widgets/canvas/checkerColors.ts`). + */ +export const DEFAULT_CHECKER_COLORS: CheckerColors = { a: '#2a2a2a', b: '#363636' }; + +/** + * Fallback tint alpha for a mask-bearing layer drawn WITHOUT a backend (a bare + * {@link compositeDocument} call in a minimal test): the mask coverage is blitted + * and a translucent flat fill laid over it. The real path (with a backend) + * colorizes the mask alpha via `source-in` at the layer opacity, matching legacy. + */ +export const MASK_TINT_ALPHA = 0.5; + +/** Dashed outline drawn around a staged-generation preview so it reads as pending, not committed. */ +const STAGED_PREVIEW_OUTLINE_COLOR = '#3b82f6'; +const STAGED_PREVIEW_OUTLINE_WIDTH = 2; +const STAGED_PREVIEW_OUTLINE_DASH = 6; + +/** Optional inputs to {@link compositeDocument}. */ +export interface CompositeOptions { + /** Clips document layers to this document-space rect without clipping the background or staged preview. */ + clipRect?: Rect | null; + /** A staged generation candidate to draw at its placement (document space). */ + stagedPreview?: { surface: RasterSurface; rect: Rect; opacity?: number } | null; + /** + * The cached checkerboard pattern tile (see {@link createCheckerboardTile}) to + * fill the ENTIRE viewport with (the canvas is an unbounded plane — the checker + * is the world, not a document backdrop). Omit or pass `null` to draw NO + * checkerboard (the "checkerboard off" state) — the cleared surface then shows + * the widget's themed `bg.inset` through it. + */ + checkerboardTile?: RasterSurface | null; + /** + * Whether `imageSmoothingEnabled` is on for this composite's `drawImage` + * blits (layer caches + staged preview). Defaults to `true` (the browser + * default) so non-viewport callers are unaffected; the engine feeds + * {@link shouldSmoothAtZoom} so zoomed-in frames composite crisp and cheap. + */ + imageSmoothing?: boolean; + /** + * Transient per-layer transform overrides (a live move/transform preview): a + * layer with an entry here is drawn through the overridden transform instead of + * its committed one. `scaleX`/`scaleY`/`rotation` fall back to the committed + * transform when absent (the move tool overrides only `x`/`y`). The mirror stays + * untouched. + */ + transformOverrides?: ReadonlyMap< + string, + { x: number; y: number; scaleX?: number; scaleY?: number; rotation?: number } + > | null; + /** + * A layer id to SKIP entirely (draw nothing for it). Used while a text-edit + * session is open: the contenteditable portal shows the layer's live text + * instead, so drawing its committed pixels underneath would double up. `null` + * (or absent) skips nothing. + */ + skipLayerId?: string | null; + /** + * The raster backend, needed to colorize mask layers (an intermediate surface + * holds the alpha stencil while the fill is composited `source-in`). When + * absent, mask layers fall back to the flat-tint approximation + * ({@link MASK_TINT_ALPHA}); the engine always supplies it. + */ + backend?: RasterBackend | null; + /** + * Returns a cached repeat tile for a mask fill (style, colour), or `null` for a + * solid fill (drawn directly). The engine caches tiles by `style:color` (like + * the checkerboard). Absent ⇒ solid fills only. + */ + maskPatternTile?: ((style: string, color: string) => RasterSurface | null) | null; + /** + * Transient per-layer content previews (a non-destructive control-filter + * preview): a layer with an entry here draws the preview surface at its returned + * layer-local output rect, through the layer transform, INSTEAD of + * its committed cache, so the document is untouched until the filter is applied. + * `null`/absent ⇒ no previews. + */ + layerPreviews?: ReadonlyMap | null; + /** When set, transiently composites only this layer (even if disabled) and suppresses staged content. */ + onlyLayerId?: string | null; + /** + * Returns a raster layer's ADJUSTED cache surface (brightness/contrast/ + * saturation/curves applied), or `null` when the layer has identity (or no) + * adjustments — in which case the committed cache surface is drawn directly. + * The engine wires this to a memoizing {@link + * import('./adjustedSurfaceCache').AdjustedSurfaceCache} so the adjusted pixels + * are NOT recomputed each frame (see that module). Only consulted for raster + * layers; absent ⇒ adjustments are ignored (a bare test call draws raw pixels). + */ + adjustedSurface?: ((layer: CanvasLayerContract, entry: LayerCacheEntry) => RasterSurface | null) | null; + /** Shared memoized surfaces for mask and control display effects. */ + derivedSurfaces?: DerivedSurfaceCache | null; + /** Optional deterministic render counters; omitted in the normal zero-overhead path. */ + diagnostics?: CanvasDiagnostics | null; +} + +type Ctx = RasterSurface['ctx']; + +/** Maps a document blend mode to the canvas `globalCompositeOperation`. Also used by `colorSample.ts`. */ +export const blendToComposite = (mode: CanvasBlendMode): GlobalCompositeOperation => + mode === 'normal' ? 'source-over' : (mode as GlobalCompositeOperation); + +const setTransformFromMat = (ctx: Ctx, m: Mat2d): void => { + ctx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f); +}; + +const identityTransform = (ctx: Ctx): void => { + ctx.setTransform(1, 0, 0, 1, 0, 0); +}; + +const getEffectiveLayerMatrix = (layer: CanvasLayerContract, opts: CompositeOptions): Mat2d => { + const override = opts.transformOverrides?.get(layer.id); + return fromTRS( + { x: override?.x ?? layer.transform.x, y: override?.y ?? layer.transform.y }, + override?.rotation ?? layer.transform.rotation, + override?.scaleX ?? layer.transform.scaleX, + override?.scaleY ?? layer.transform.scaleY + ); +}; + +const isDefinitelyOffscreen = ( + layer: CanvasLayerContract, + entry: LayerCacheEntry, + view: Mat2d, + target: RasterSurface, + opts: CompositeOptions +): boolean => { + const bounds = transformBounds(multiply(view, getEffectiveLayerMatrix(layer, opts)), entry.rect); + if (![bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite)) { + return false; + } + return intersect(bounds, { height: target.height, width: target.width, x: 0, y: 0 }) === null; +}; + +/** + * Builds a small, reusable two-tone checkerboard tile (a 2×2 grid of `squarePx` + * cells) through the {@link RasterBackend} seam. The engine creates this ONCE and + * feeds it back via {@link CompositeOptions.checkerboardTile}; each frame then + * only `createPattern`s over it, so no per-cell fill loop runs per frame. + */ +export const createCheckerboardTile = ( + backend: RasterBackend, + colors: CheckerColors = DEFAULT_CHECKER_COLORS, + squarePx: number = CHECKERBOARD_SQUARE_PX +): RasterSurface => { + const size = squarePx * 2; + const tile = backend.createSurface(size, size); + const ctx = tile.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, size, size); + // Base color across the whole tile, then the alternating cells on the diagonal. + ctx.fillStyle = colors.a; + ctx.fillRect(0, 0, size, size); + ctx.fillStyle = colors.b; + ctx.fillRect(0, 0, squarePx, squarePx); + ctx.fillRect(squarePx, squarePx, squarePx, squarePx); + return tile; +}; + +/** + * Fills the ENTIRE viewport with the checkerboard pattern. The canvas is a + * virtually infinite plane (like legacy): the checker IS the world surround, not + * a document backdrop — the document rect is no longer a visual boundary, so the + * contract's `background` field no longer renders. When no tile is provided the + * checkerboard is off and the cleared surface shows the widget's `bg.inset`. + * + * The pattern is laid with the identity transform in place, anchoring its cells + * to the screen (canvas) origin at a fixed pixel size — like legacy, which pins + * the pattern to the stage — so it stays visually stable and never swims or + * scales while panning/zooming. + */ +const drawBackground = (ctx: Ctx, target: RasterSurface, tile: RasterSurface | null): void => { + if (!tile) { + // Checkerboard disabled: leave the cleared surface showing `bg.inset`. + return; + } + const pattern = ctx.createPattern(tile.canvas, 'repeat'); + if (!pattern) { + return; + } + ctx.fillStyle = pattern; + ctx.fillRect(0, 0, target.width, target.height); +}; + +const isMaskLayer = ( + layer: CanvasLayerContract +): layer is Extract => + layer.type === 'regional_guidance' || layer.type === 'inpaint_mask'; + +/** + * Draws a mask-bearing layer as a TINTED, TRANSLUCENT overlay. With a backend + * available (the engine path) it colorizes the mask's alpha stencil with the + * layer's fill colour/pattern (`source-in`) on an intermediate surface, then + * blits that through the current (already transformed, `globalAlpha = + * layer.opacity`) context — legacy's `source-in` compositing-rect technique. + * Without a backend it falls back to blitting the coverage plus a flat + * translucent fill. + */ +const drawMaskLayer = ( + ctx: Ctx, + layer: Extract, + surface: RasterSurface, + sourceVersion: number, + origin: Vec2, + opts: CompositeOptions +): void => { + const fill = layer.mask.fill; + if (opts.backend) { + const tile = opts.maskPatternTile ? opts.maskPatternTile(fill.style, fill.color) : null; + const colorized = opts.derivedSurfaces + ? opts.derivedSurfaces.get({ + create: (target) => colorizeMask(opts.backend!, surface, surface.width, surface.height, fill, tile, target), + kind: 'mask-fill', + layerId: layer.id, + paramsKey: `${fill.style}:${fill.color}`, + source: surface, + sourceVersion, + }) + : colorizeMask(opts.backend, surface, surface.width, surface.height, fill, tile); + // The outer loop already set globalAlpha = layer.opacity; blit the colorized + // overlay at the mask's local content origin. + ctx.drawImage(colorized.canvas, origin.x, origin.y); + return; + } + // Backend-less fallback (bare test call): coverage + flat translucent fill. + ctx.drawImage(surface.canvas, origin.x, origin.y); + ctx.globalAlpha = layer.opacity * MASK_TINT_ALPHA; + ctx.fillStyle = fill.color; + ctx.fillRect(origin.x, origin.y, surface.width, surface.height); +}; + +/** + * Draws one cached layer through its transform, applying opacity/blend and any + * transient transform override. Mask-bearing layers are colorized; everything + * else is a straight blit of its content-sized cache surface. + */ +const drawCachedLayer = ( + ctx: Ctx, + layer: CanvasLayerContract, + entry: LayerCacheEntry, + view: Mat2d, + opts: CompositeOptions +): void => { + ctx.save(); + ctx.globalAlpha = layer.opacity; + ctx.globalCompositeOperation = blendToComposite(layer.blendMode); + + const layerMat = getEffectiveLayerMatrix(layer, opts); + setTransformFromMat(ctx, multiply(view, layerMat)); + + // The cache surface holds pixels for `entry.rect` in layer-local space; draw + // it at that local origin (offset paint/mask layers place their content off-zero). + const origin = { x: entry.rect.x, y: entry.rect.y }; + const preview = opts.onlyLayerId ? null : (opts.layerPreviews?.get(layer.id) ?? null); + if (preview) { + // Non-destructive filter preview: draw the full backend output at its + // layer-local rect (through the already-applied layer transform), including + // the same display-only control transparency effect used after commit. + const displayPreview = + layer.type === 'control' && layer.withTransparencyEffect && opts.backend + ? opts.derivedSurfaces + ? opts.derivedSurfaces.get({ + create: (target) => + renderControlTransparency( + opts.backend!, + preview.surface, + preview.surface.width, + preview.surface.height, + target + ), + kind: 'control-transparency', + layerId: layer.id, + paramsKey: 'preview', + source: preview.surface, + sourceVersion: 0, + }) + : renderControlTransparency(opts.backend, preview.surface, preview.surface.width, preview.surface.height) + : preview.surface; + ctx.drawImage(displayPreview.canvas, preview.rect.x, preview.rect.y); + } else if (isMaskLayer(layer)) { + drawMaskLayer(ctx, layer, entry.surface, entry.version, origin, opts); + } else if (layer.type === 'control' && layer.withTransparencyEffect && opts.backend) { + // Display-only lightness→alpha effect (legacy `LightnessToAlphaFilter`): dark + // areas of the control map drop out so underlying content shows through. + const effect = opts.derivedSurfaces + ? opts.derivedSurfaces.get({ + create: (target) => + renderControlTransparency(opts.backend!, entry.surface, entry.surface.width, entry.surface.height, target), + kind: 'control-transparency', + layerId: layer.id, + paramsKey: 'committed', + source: entry.surface, + sourceVersion: entry.version, + }) + : renderControlTransparency(opts.backend, entry.surface, entry.surface.width, entry.surface.height); + ctx.drawImage(effect.canvas, origin.x, origin.y); + } else { + // Raster layers may carry non-destructive adjustments; the engine supplies a + // memoized adjusted surface (never recomputed per frame). Fall back to the raw + // cache when there are no adjustments or no provider. + const adjusted = layer.type === 'raster' && opts.adjustedSurface ? opts.adjustedSurface(layer, entry) : null; + ctx.drawImage((adjusted ?? entry.surface).canvas, origin.x, origin.y); + } + + ctx.restore(); +}; + +/** + * Composites `doc` onto `target`, using `caches` for each layer's pixels and + * `view` as the document→screen transform. + */ +export const compositeDocument = ( + target: RasterSurface, + doc: CanvasDocumentContractV2, + caches: LayerCacheStore, + view: Mat2d, + opts: CompositeOptions = {} +): void => { + const ctx = target.ctx; + opts.diagnostics?.increment('compositeFrames'); + + ctx.save(); + + // Smoothing policy for all layer/staged `drawImage` blits below. Set once + // under the outer save (inner per-layer save/restore preserves it). Off when + // zoomed in keeps magnified pixels crisp and skips the costly bilinear upscale. + ctx.imageSmoothingEnabled = opts.imageSmoothing ?? true; + + // Clear the whole target in screen space, then lay the checkerboard across the + // entire viewport — the canvas is an unbounded plane, so the checker is the + // world, not a document backdrop. With the checkerboard off the cleared surface + // shows the widget's themed `bg.inset` through it. + identityTransform(ctx); + ctx.clearRect(0, 0, target.width, target.height); + drawBackground(ctx, target, opts.checkerboardTile ?? null); + + if (opts.clipRect) { + ctx.save(); + setTransformFromMat(ctx, view); + ctx.beginPath(); + ctx.rect(opts.clipRect.x, opts.clipRect.y, opts.clipRect.width, opts.clipRect.height); + ctx.clip(); + } + + // Composite in STRICT GROUP ORDER (legacy `arrangeEntities` / `CanvasEntityRendererModule`): + // raster (bottom) < control < regional guidance < inpaint mask (top). This + // matches the layers panel, which renders these as fixed grouped sections, and + // makes a layer's global insertion index irrelevant ACROSS groups (a raster + // created after a control layer must still draw below it). WITHIN a group the + // panel/array relative order is preserved. The `stagedPreview` lands on top of + // all groups below. Index 0 is top-most, so iterate the array in reverse. + const drawGroup = (rank: number): void => { + for (let i = doc.layers.length - 1; i >= 0; i--) { + const layer = doc.layers[i]; + if ( + !layer || + (!layer.isEnabled && layer.id !== opts.onlyLayerId) || + layer.id === opts.skipLayerId || + (opts.onlyLayerId && layer.id !== opts.onlyLayerId) || + layerGroupRank(layer) !== rank + ) { + continue; + } + opts.diagnostics?.increment('layersConsidered'); + const entry = caches.get(layer.id); + // Skip layers with no cache or an empty content rect (a brand-new / cleared + // paint / mask layer holds no pixels — nothing to draw). + if (!entry || entry.rect.width <= 0 || entry.rect.height <= 0) { + continue; + } + if (isDefinitelyOffscreen(layer, entry, view, target, opts)) { + opts.diagnostics?.increment('layersCulled'); + continue; + } + drawCachedLayer(ctx, layer, entry, view, opts); + opts.diagnostics?.increment('layersDrawn'); + } + }; + for (let rank = 0; rank < LAYER_GROUP_COUNT; rank++) { + drawGroup(rank); + } + + if (opts.clipRect) { + ctx.restore(); + } + + // Staged generation preview over its bbox (document space), with a subtle + // dashed outline so the pending result reads distinctly from committed pixels. + const staged = opts.onlyLayerId ? null : opts.stagedPreview; + if (staged) { + ctx.save(); + setTransformFromMat(ctx, view); + ctx.globalAlpha = staged.opacity ?? 1; + ctx.drawImage(staged.surface.canvas, staged.rect.x, staged.rect.y, staged.rect.width, staged.rect.height); + // Keep the outline visually constant regardless of zoom by dividing the + // document-space stroke by the view scale (√det of the linear part). + const viewScale = Math.sqrt(Math.abs(view.a * view.d - view.b * view.c)) || 1; + ctx.globalAlpha = 1; + ctx.strokeStyle = STAGED_PREVIEW_OUTLINE_COLOR; + ctx.lineWidth = STAGED_PREVIEW_OUTLINE_WIDTH / viewScale; + ctx.setLineDash([STAGED_PREVIEW_OUTLINE_DASH / viewScale, STAGED_PREVIEW_OUTLINE_DASH / viewScale]); + ctx.strokeRect(staged.rect.x, staged.rect.y, staged.rect.width, staged.rect.height); + ctx.setLineDash([]); + ctx.restore(); + } + + ctx.restore(); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/controlTransparency.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/controlTransparency.test.ts new file mode 100644 index 00000000000..2e2feb91e2c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/controlTransparency.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; + +import type { RasterBackend, RasterSurface } from './raster'; + +import { applyLightnessToAlpha, renderControlTransparency } from './controlTransparency'; + +/** Builds a flat RGBA `Uint8ClampedArray` from a list of [r, g, b, a] pixels. */ +const pixels = (...rgba: [number, number, number, number][]): Uint8ClampedArray => new Uint8ClampedArray(rgba.flat()); + +describe('applyLightnessToAlpha', () => { + it('sets alpha to the HSL lightness of each pixel, clamped to the existing alpha', () => { + const data = pixels( + [255, 255, 255, 255], // white: lightness 255 -> alpha stays 255 + [0, 0, 0, 255], // black: lightness 0 -> alpha becomes 0 + [128, 128, 128, 255], // mid-gray: lightness 128 -> alpha 128 + [100, 50, 200, 255], // colored: min 50, max 200 -> lightness 125 -> alpha 125 + [255, 255, 255, 100] // opaque-white but a=100: lightness 255, min(100, 255) keeps 100 + ); + + applyLightnessToAlpha(data); + + // Assert the resulting alpha channel for each pixel. + expect(data[3]).toBe(255); + expect(data[7]).toBe(0); + expect(data[11]).toBe(128); + expect(data[15]).toBe(125); + expect(data[19]).toBe(100); + }); + + it('leaves the RGB channels untouched', () => { + const data = pixels( + [255, 255, 255, 255], + [0, 0, 0, 255], + [128, 128, 128, 255], + [100, 50, 200, 255], + [255, 255, 255, 100] + ); + + applyLightnessToAlpha(data); + + // Only the alpha byte of every quad may change; the RGB bytes are preserved. + expect([data[0], data[1], data[2]]).toEqual([255, 255, 255]); + expect([data[4], data[5], data[6]]).toEqual([0, 0, 0]); + expect([data[8], data[9], data[10]]).toEqual([128, 128, 128]); + expect([data[12], data[13], data[14]]).toEqual([100, 50, 200]); + expect([data[16], data[17], data[18]]).toEqual([255, 255, 255]); + }); + + it('mutates the buffer in place and returns void', () => { + const data = pixels([0, 0, 0, 255]); + const same = data; + + const result = applyLightnessToAlpha(data); + + // Same reference, mutated in place; the function returns nothing. + expect(result).toBeUndefined(); + expect(data).toBe(same); + expect(same[3]).toBe(0); + }); + + it('handles an empty buffer without throwing', () => { + const data = new Uint8ClampedArray(0); + expect(() => applyLightnessToAlpha(data)).not.toThrow(); + expect(data).toHaveLength(0); + }); +}); + +/** A minimal `RasterSurface` recording drawImage/getImageData/putImageData against a fed bitmap. */ +interface FakeSurface extends RasterSurface { + readonly drawImageArgs: unknown[][]; + readonly getImageDataArgs: unknown[][]; + readonly putImageDataCalls: ImageData[]; +} + +/** + * A minimal but faithful `RasterBackend` for `renderControlTransparency`: every + * surface's `getImageData` returns a fresh copy of `sourcePixels`, and its + * `putImageData` is recorded so the transform's output can be inspected. + */ +const createFakeBackend = (sourcePixels: Uint8ClampedArray): RasterBackend & { readonly created: FakeSurface[] } => { + const created: FakeSurface[] = []; + + const createSurface = (width: number, height: number): FakeSurface => { + const drawImageArgs: unknown[][] = []; + const getImageDataArgs: unknown[][] = []; + const putImageDataCalls: ImageData[] = []; + + const ctx = { + clearRect: () => {}, + drawImage: (...args: unknown[]) => { + drawImageArgs.push(args); + }, + getImageData: (...args: unknown[]): ImageData => { + getImageDataArgs.push(args); + // Return a fresh copy so callers mutate their own buffer, not the source. + const data = new Uint8ClampedArray(sourcePixels); + return { colorSpace: 'srgb', data, height, width } as unknown as ImageData; + }, + globalAlpha: 1, + globalCompositeOperation: 'source-over' as GlobalCompositeOperation, + putImageData: (imageData: ImageData) => { + putImageDataCalls.push(imageData); + }, + setTransform: () => {}, + } as unknown as OffscreenCanvasRenderingContext2D; + + const surface: FakeSurface = { + canvas: { height, width } as unknown as OffscreenCanvas, + ctx, + drawImageArgs, + getImageDataArgs, + height, + putImageDataCalls, + resize: () => {}, + width, + }; + created.push(surface); + return surface; + }; + + return { + createImageBitmap: () => Promise.resolve({ close: () => {}, height: 0, width: 0 } as unknown as ImageBitmap), + createSurface, + created, + encodeSurface: (surface: RasterSurface, type = 'image/png') => + Promise.resolve(new Blob([`fake-${surface.width}x${surface.height}`], { type })), + }; +}; + +describe('renderControlTransparency', () => { + it('allocates the output surface at the requested dimensions', () => { + const backend = createFakeBackend(pixels([0, 0, 0, 255])); + const cache = backend.createSurface(64, 48); + + const out = renderControlTransparency(backend, cache, 64, 48); + + expect(out.width).toBe(64); + expect(out.height).toBe(48); + // The returned surface is a NEW allocation, not the cache itself. + expect(out).not.toBe(cache); + }); + + it('draws the cache canvas into the output surface', () => { + const backend = createFakeBackend(pixels([0, 0, 0, 255])); + const cache = backend.createSurface(10, 10); + + const out = renderControlTransparency(backend, cache, 10, 10) as FakeSurface; + + // The cache's canvas is blitted into the output at the origin. + expect(out.drawImageArgs).toHaveLength(1); + expect(out.drawImageArgs[0]![0]).toBe(cache.canvas); + expect(out.drawImageArgs[0]!.slice(1)).toEqual([0, 0]); + }); + + it('applies the lightness->alpha transform: opaque black pixels become fully transparent', () => { + // Feed a bitmap of opaque black pixels; after the transform each alpha is 0. + const backend = createFakeBackend(pixels([0, 0, 0, 255], [0, 0, 0, 255])); + const cache = backend.createSurface(2, 1); + + const out = renderControlTransparency(backend, cache, 2, 1) as FakeSurface; + + // getImageData was read over the full surface rect, and the result put back. + expect(out.getImageDataArgs).toHaveLength(1); + expect(out.getImageDataArgs[0]).toEqual([0, 0, 2, 1]); + expect(out.putImageDataCalls).toHaveLength(1); + + const result = out.putImageDataCalls[0]!.data; + // Black -> lightness 0 -> alpha 0; RGB channels are preserved. + expect(result[3]).toBe(0); + expect(result[7]).toBe(0); + expect([result[0], result[1], result[2]]).toEqual([0, 0, 0]); + }); + + it('applies the lightness->alpha transform to a mixed bitmap', () => { + const backend = createFakeBackend( + pixels( + [255, 255, 255, 255], // white -> alpha 255 + [0, 0, 0, 255], // black -> alpha 0 + [100, 50, 200, 255] // colored -> lightness 125 -> alpha 125 + ) + ); + const cache = backend.createSurface(3, 1); + + const out = renderControlTransparency(backend, cache, 3, 1) as FakeSurface; + + const result = out.putImageDataCalls[0]!.data; + expect(result[3]).toBe(255); + expect(result[7]).toBe(0); + expect(result[11]).toBe(125); + // RGB untouched by the transform. + expect([result[8], result[9], result[10]]).toEqual([100, 50, 200]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/controlTransparency.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/controlTransparency.ts new file mode 100644 index 00000000000..20f99b3a6e0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/controlTransparency.ts @@ -0,0 +1,64 @@ +/** + * The control-layer transparency effect (legacy `LightnessToAlphaFilter`). + * + * A control layer with `withTransparencyEffect` renders its source so DARK areas + * become transparent — for an edge/depth/pose map, the black background drops out + * and the underlying content shows through. This mirrors legacy + * `features/controlLayers/konva/filters.ts`: per pixel, HSL lightness + * `(min(r,g,b) + max(r,g,b)) / 2` becomes the new alpha, clamped to never EXCEED + * the pixel's existing alpha. + * + * It is DISPLAY-ONLY: at generation time the control image is composited at full + * opacity with NO transparency effect (see `generation/canvas/compositePlan.ts` + * `toControlLayerRef`), exactly like legacy (which rasterizes control with + * `filters: []`, `bg: 'black'`). + * + * Zero React, zero import-time side effects. + */ + +import type { RasterBackend, RasterSurface } from './raster'; + +/** + * Applies the lightness→alpha transform to an RGBA pixel buffer in place: each + * pixel's alpha becomes `min(alpha, lightness)` where `lightness` is the HSL + * lightness of its RGB. Pure — the unit-testable core of the effect. + */ +export const applyLightnessToAlpha = (data: Uint8ClampedArray): void => { + for (let i = 0; i + 3 < data.length; i += 4) { + const r = data[i] ?? 0; + const g = data[i + 1] ?? 0; + const b = data[i + 2] ?? 0; + const a = data[i + 3] ?? 0; + const lightness = (Math.min(r, g, b) + Math.max(r, g, b)) / 2; + data[i + 3] = Math.min(a, lightness); + } +}; + +/** + * Produces a transparency-effect copy of a control layer's cache surface (same + * dimensions): the cache is drawn, its pixels transformed by + * {@link applyLightnessToAlpha}, and the result returned. The caller blits it + * through the layer transform in place of the raw cache. + */ +export const renderControlTransparency = ( + backend: RasterBackend, + cache: RasterSurface, + width: number, + height: number, + target: RasterSurface | null = null +): RasterSurface => { + const out = target ?? backend.createSurface(width, height); + if (out.width !== width || out.height !== height) { + out.resize(width, height); + } + const ctx = out.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, width, height); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'source-over'; + ctx.drawImage(cache.canvas, 0, 0); + const imageData = ctx.getImageData(0, 0, width, height); + applyLightnessToAlpha(imageData.data); + ctx.putImageData(imageData, 0, 0); + return out; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/decodedBitmapPool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/decodedBitmapPool.test.ts new file mode 100644 index 00000000000..21238816486 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/decodedBitmapPool.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createDecodedBitmapPool } from './decodedBitmapPool'; + +const bitmap = () => { + const close = vi.fn(); + return { close, height: 20, width: 10 } as unknown as ImageBitmap; +}; + +describe('DecodedBitmapPool', () => { + it('coalesces concurrent decodes and closes the bitmap after the final lease', async () => { + const decoded = bitmap(); + const decode = vi.fn(() => Promise.resolve(decoded)); + const byteChanges: number[] = []; + const pool = createDecodedBitmapPool({ onBytesChange: (bytes) => byteChanges.push(bytes) }); + + const [first, second] = await Promise.all([pool.acquire('image', decode), pool.acquire('image', decode)]); + + expect(decode).toHaveBeenCalledTimes(1); + expect(pool.byteSize()).toBe(800); + first.release(); + first.release(); + expect(decoded.close).not.toHaveBeenCalled(); + second.release(); + second.release(); + expect(decoded.close).toHaveBeenCalledTimes(1); + expect(pool.byteSize()).toBe(0); + expect(byteChanges).toEqual([800, 0]); + }); + + it('closes a decode that completes after disposal', async () => { + let resolve!: (value: ImageBitmap) => void; + const decode = () => + new Promise((next) => { + resolve = next; + }); + const pool = createDecodedBitmapPool(); + const pending = pool.acquire('late', decode); + pool.dispose(); + const decoded = bitmap(); + resolve(decoded); + + await expect(pending).rejects.toThrow(/disposed/i); + expect(decoded.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/decodedBitmapPool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/decodedBitmapPool.ts new file mode 100644 index 00000000000..440353dad11 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/decodedBitmapPool.ts @@ -0,0 +1,159 @@ +export interface DecodedBitmapLease { + readonly bitmap: ImageBitmap; + release(): void; +} + +export interface DecodedBitmapPool { + acquire( + key: string, + decode: (signal?: AbortSignal) => Promise, + signal?: AbortSignal + ): Promise; + byteSize(): number; + dispose(): void; +} + +interface PoolEntry { + controller: AbortController; + promise: Promise; + bitmap: ImageBitmap | null; + bytes: number; + leases: number; +} + +const bitmapBytes = (bitmap: ImageBitmap): number => bitmap.width * bitmap.height * 4; + +/** A decode-coalescing pool whose bitmaps live only while callers hold leases. */ +export const createDecodedBitmapPool = ( + options: { onBytesChange?: (bytes: number) => void } = {} +): DecodedBitmapPool => { + const entries = new Map(); + let totalBytes = 0; + let disposed = false; + + const reportBytes = (): void => options.onBytesChange?.(totalBytes); + + const releaseInterest = (key: string, entry: PoolEntry): void => { + entry.leases = Math.max(0, entry.leases - 1); + if (entry.leases !== 0 || entries.get(key) !== entry) { + return; + } + entries.delete(key); + if (entry.bitmap) { + entry.bitmap.close(); + totalBytes = Math.max(0, totalBytes - entry.bytes); + entry.bitmap = null; + entry.bytes = 0; + reportBytes(); + } else { + entry.controller.abort(); + } + }; + + const waitForBitmap = ( + promise: Promise, + signal: AbortSignal | undefined, + releaseOnAbort: () => void + ): Promise => { + if (!signal) { + return promise; + } + if (signal.aborted) { + return Promise.reject(signal.reason); + } + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort); + releaseOnAbort(); + reject(signal.reason); + }; + signal.addEventListener('abort', onAbort, { once: true }); + promise.then( + (bitmap) => { + signal.removeEventListener('abort', onAbort); + resolve(bitmap); + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort); + reject(error); + } + ); + }); + }; + + const acquire = async ( + key: string, + decode: (signal?: AbortSignal) => Promise, + signal?: AbortSignal + ): Promise => { + if (disposed) { + throw new Error('DecodedBitmapPool is disposed.'); + } + let entry = entries.get(key); + if (!entry) { + const created: PoolEntry = { + bitmap: null, + bytes: 0, + controller: new AbortController(), + leases: 0, + promise: Promise.resolve(null as never), + }; + created.promise = decode(created.controller.signal).then((bitmap) => { + if (disposed) { + bitmap.close(); + throw new Error('DecodedBitmapPool was disposed during decode.'); + } + if (entries.get(key) !== created || created.leases === 0) { + bitmap.close(); + throw created.controller.signal.reason ?? new Error('DecodedBitmapPool discarded an unowned decode.'); + } + created.bitmap = bitmap; + created.bytes = bitmapBytes(bitmap); + totalBytes += created.bytes; + reportBytes(); + return bitmap; + }); + entry = created; + entries.set(key, entry); + } + entry.leases += 1; + let released = false; + const release = (): void => { + if (released) { + return; + } + released = true; + releaseInterest(key, entry); + }; + let bitmap: ImageBitmap; + try { + bitmap = await waitForBitmap(entry.promise, signal, release); + } catch (error) { + release(); + throw error; + } + + return { bitmap, release }; + }; + + const dispose = (): void => { + if (disposed) { + return; + } + disposed = true; + for (const entry of entries.values()) { + entry.controller.abort(); + if (entry.bitmap) { + entry.bitmap.close(); + entry.bitmap = null; + } + } + entries.clear(); + if (totalBytes !== 0) { + totalBytes = 0; + reportBytes(); + } + }; + + return { acquire, byteSize: () => totalBytes, dispose }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/derivedSurfaceCache.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/derivedSurfaceCache.test.ts new file mode 100644 index 00000000000..8f708d142b5 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/derivedSurfaceCache.test.ts @@ -0,0 +1,110 @@ +import { createCanvasDiagnostics } from '@workbench/canvas-engine/diagnostics'; +import { describe, expect, it } from 'vitest'; + +import type { StubRasterSurface } from './raster.testStub'; + +import { createDerivedSurfaceCache } from './derivedSurfaceCache'; +import { createTestStubRasterBackend } from './raster.testStub'; + +describe('createDerivedSurfaceCache', () => { + it('reuses an unchanged effect and rebuilds exactly once for a changed key', () => { + const backend = createTestStubRasterBackend(); + const source = backend.createSurface(10, 20); + const cache = createDerivedSurfaceCache(); + let builds = 0; + const get = (sourceVersion: number, paramsKey: string) => + cache.get({ + create: (target) => { + builds += 1; + return target ?? backend.createSurface(source.width, source.height); + }, + kind: 'adjustments', + layerId: 'a', + paramsKey, + source, + sourceVersion, + }); + + const first = get(1, 'bright'); + expect(get(1, 'bright')).toBe(first); + expect(builds).toBe(1); + + expect(get(2, 'bright')).toBe(first); + expect(get(2, 'contrast')).toBe(first); + expect(builds).toBe(3); + }); + + it('keeps unrelated layer effects and removes every effect for a deleted layer', () => { + const backend = createTestStubRasterBackend(); + const cache = createDerivedSurfaceCache(); + const create = (layerId: string, kind: 'mask-fill' | 'control-transparency') => { + const source = backend.createSurface(4, 4); + return cache.get({ + create: () => backend.createSurface(4, 4), + kind, + layerId, + paramsKey: 'x', + source, + sourceVersion: 0, + }); + }; + + create('a', 'mask-fill'); + create('a', 'control-transparency'); + const b = create('b', 'mask-fill'); + cache.deleteLayer('a'); + + expect(cache.size()).toBe(1); + expect(cache.byteSize()).toBe(4 * 4 * 4); + expect(create('b', 'mask-fill')).not.toBe(b); // different source identity is guarded + }); + + it('evicts least-recently-used entries to a deterministic byte budget', () => { + const backend = createTestStubRasterBackend(); + const cache = createDerivedSurfaceCache(); + const surfaces = new Map(); + const get = (layerId: string) => { + const source = surfaces.get(layerId) ?? backend.createSurface(10, 10); + surfaces.set(layerId, source); + return cache.get({ + create: () => backend.createSurface(10, 10), + kind: 'mask-fill', + layerId, + paramsKey: 'solid:red', + source, + sourceVersion: 0, + }); + }; + + get('a'); + get('b'); + get('a'); // a is now most recently used + expect(cache.evictToBudget(400)).toEqual(['b']); + expect(cache.byteSize()).toBe(400); + }); + + it('reports cache hits, misses, evictions, and allocated bytes when diagnostics are enabled', () => { + const backend = createTestStubRasterBackend(); + const diagnostics = createCanvasDiagnostics(true); + const cache = createDerivedSurfaceCache(diagnostics); + const source = backend.createSurface(5, 5); + const request = { + create: () => backend.createSurface(5, 5), + kind: 'adjustments' as const, + layerId: 'a', + paramsKey: 'x', + source, + sourceVersion: 0, + }; + cache.get(request); + cache.get(request); + cache.evictToBudget(0); + + expect(diagnostics.snapshot()).toMatchObject({ + allocatedDerivedBytes: 100, + derivedCacheEvictions: 1, + derivedCacheHits: 1, + derivedCacheMisses: 1, + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/derivedSurfaceCache.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/derivedSurfaceCache.ts new file mode 100644 index 00000000000..70c36b15171 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/derivedSurfaceCache.ts @@ -0,0 +1,117 @@ +import type { CanvasDiagnostics } from '@workbench/canvas-engine/diagnostics'; + +import type { RasterSurface } from './raster'; + +export type DerivedSurfaceKind = 'adjustments' | 'mask-fill' | 'control-transparency'; + +export interface DerivedSurfaceRequest { + layerId: string; + sourceVersion: number; + kind: DerivedSurfaceKind; + paramsKey: string; + source: RasterSurface; + create(target: RasterSurface | null): RasterSurface; +} + +export interface DerivedSurfaceCache { + get(request: DerivedSurfaceRequest): RasterSurface; + delete(layerId: string, kind: DerivedSurfaceKind): void; + deleteLayer(layerId: string): void; + byteSize(): number; + size(): number; + evictToBudget(budgetBytes: number): string[]; + dispose(): void; +} + +interface CacheEntry { + readonly layerId: string; + readonly kind: DerivedSurfaceKind; + paramsKey: string; + source: RasterSurface; + sourceVersion: number; + surface: RasterSurface; + lastUsed: number; +} + +const BYTES_PER_PIXEL = 4; +const entryKey = (layerId: string, kind: DerivedSurfaceKind): string => `${layerId}\u0000${kind}`; +const surfaceBytes = (surface: RasterSurface): number => surface.width * surface.height * BYTES_PER_PIXEL; + +export const createDerivedSurfaceCache = (diagnostics?: CanvasDiagnostics): DerivedSurfaceCache => { + const entries = new Map(); + let tick = 0; + + const get = (request: DerivedSurfaceRequest): RasterSurface => { + const key = entryKey(request.layerId, request.kind); + const existing = entries.get(key); + tick += 1; + if ( + existing && + existing.source === request.source && + existing.sourceVersion === request.sourceVersion && + existing.paramsKey === request.paramsKey + ) { + diagnostics?.increment('derivedCacheHits'); + existing.lastUsed = tick; + return existing.surface; + } + + diagnostics?.increment('derivedCacheMisses'); + const canReuse = existing?.source === request.source; + const surface = request.create(canReuse ? existing.surface : null); + if (!canReuse) { + diagnostics?.add('allocatedDerivedBytes', surfaceBytes(surface)); + } + entries.set(key, { + kind: request.kind, + lastUsed: tick, + layerId: request.layerId, + paramsKey: request.paramsKey, + source: request.source, + sourceVersion: request.sourceVersion, + surface, + }); + return surface; + }; + + return { + byteSize: () => { + let bytes = 0; + for (const entry of entries.values()) { + bytes += surfaceBytes(entry.surface); + } + return bytes; + }, + delete: (layerId, kind) => { + entries.delete(entryKey(layerId, kind)); + }, + deleteLayer: (layerId) => { + for (const [key, entry] of entries) { + if (entry.layerId === layerId) { + entries.delete(key); + } + } + }, + dispose: () => entries.clear(), + evictToBudget: (budgetBytes) => { + let bytes = 0; + for (const entry of entries.values()) { + bytes += surfaceBytes(entry.surface); + } + const evicted: string[] = []; + const oldestFirst = [...entries.entries()].sort((a, b) => a[1].lastUsed - b[1].lastUsed); + for (const [key, entry] of oldestFirst) { + if (bytes <= budgetBytes) { + break; + } + entries.delete(key); + bytes -= surfaceBytes(entry.surface); + evicted.push(entry.layerId); + diagnostics?.increment('derivedCacheEvictions'); + } + return evicted; + }, + get, + size: () => entries.size, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/fontLoader.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/fontLoader.test.ts new file mode 100644 index 00000000000..d5cae5e72bf --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/fontLoader.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { FontLoadApi } from './fontLoader'; + +import { createFontLoader } from './fontLoader'; + +/** A fake fonts api with a controllable `check` and a deferred `load`. */ +const createFakeFonts = (initiallyLoaded = false) => { + let loaded = initiallyLoaded; + let resolveLoad: (() => void) | null = null; + const load = vi.fn( + (_font: string): Promise => + new Promise((resolve) => { + resolveLoad = () => { + loaded = true; + resolve(); + }; + }) + ); + const api: FontLoadApi = { + check: () => loaded, + load, + }; + return { api, load, settle: () => resolveLoad?.() }; +}; + +const flush = (): Promise => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +describe('createFontLoader', () => { + it('is a silent no-op with no api (node-safe)', () => { + const loader = createFontLoader(null); + const onReady = vi.fn(); + loader.ensure('400 20px Inter', onReady); + expect(onReady).not.toHaveBeenCalled(); + }); + + it('does not load or call onReady when the font is already available', () => { + const { api, load } = createFakeFonts(true); + const loader = createFontLoader(api); + const onReady = vi.fn(); + loader.ensure('400 20px Inter', onReady); + expect(load).not.toHaveBeenCalled(); + expect(onReady).not.toHaveBeenCalled(); + }); + + it('kicks a load for an unavailable font and calls onReady when it resolves', async () => { + const { api, load, settle } = createFakeFonts(false); + const loader = createFontLoader(api); + const onReady = vi.fn(); + loader.ensure('400 20px Inter', onReady); + expect(load).toHaveBeenCalledTimes(1); + expect(onReady).not.toHaveBeenCalled(); + settle(); + await flush(); + expect(onReady).toHaveBeenCalledTimes(1); + }); + + it('dedupes concurrent loads of the same font (one load, both onReady fire)', async () => { + const { api, load, settle } = createFakeFonts(false); + const loader = createFontLoader(api); + const onReadyA = vi.fn(); + const onReadyB = vi.fn(); + loader.ensure('400 20px Inter', onReadyA); + loader.ensure('400 20px Inter', onReadyB); + expect(load).toHaveBeenCalledTimes(1); + settle(); + await flush(); + expect(onReadyA).toHaveBeenCalledTimes(1); + expect(onReadyB).toHaveBeenCalledTimes(1); + }); + + it('swallows a check() that throws (treats the font as unavailable)', () => { + const api: FontLoadApi = { + check: () => { + throw new Error('bad font string'); + }, + load: vi.fn(() => Promise.resolve()), + }; + const loader = createFontLoader(api); + expect(() => loader.ensure('garbage', vi.fn())).not.toThrow(); + expect(api.load).toHaveBeenCalledTimes(1); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/fontLoader.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/fontLoader.ts new file mode 100644 index 00000000000..5d5759aaf97 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/fontLoader.ts @@ -0,0 +1,85 @@ +/** + * Web-font readiness for text layers. + * + * A text layer's rasterized metrics depend on the font actually being available: + * if a web font isn't loaded yet, the platform substitutes a fallback and + * `measureText` reports the WRONG advances, so the cached text pixels (and the + * layer's extent) would be wrong until something else happened to re-rasterize. + * + * This loader is the seam the engine uses to fix that without importing the DOM + * into the rasterizer: given a `FontLoadApi` (the browser's `document.fonts`, or + * a fake in tests), it kicks a load for a not-yet-available font exactly once + * (concurrent requests for the same font string share one in-flight promise) and + * invokes `onReady` when it resolves — the engine's callback re-rasterizes the + * affected layer. A `null` api (node, or a browser without `document.fonts`) is + * a silent no-op, so importing this module and the engine stays node-safe. + * + * The loader holds NO permanent "loaded" set — it trusts `api.check()` (the + * browser flips it to `true` post-load) as the source of truth and only tracks + * in-flight promises (cleared on settle). Combined with the per-engine instance, + * that keeps it free of cross-test global state. + * + * Zero React, zero import-time side effects. + */ + +/** The minimal slice of `FontFaceSet` the loader needs (so tests can inject a fake). */ +export interface FontLoadApi { + /** Whether every face for the CSS `font` shorthand is loaded and usable. */ + check(font: string): boolean; + /** Loads the faces for the CSS `font` shorthand; resolves when they're ready. */ + load(font: string): Promise; +} + +/** A per-engine font loader bound to one `FontLoadApi` (or `null` in node). */ +export interface FontLoader { + /** + * Ensures `font` is available, invoking `onReady` once when a pending load + * resolves. A no-op (never calls `onReady`) when there is no api or the font + * is already available — the caller's synchronous rasterize already used the + * correct metrics in that case. + */ + ensure(font: string, onReady: () => void): void; +} + +/** `check` can throw on an unparseable font string; treat a throw as "not available". */ +const safeCheck = (api: FontLoadApi, font: string): boolean => { + try { + return api.check(font); + } catch { + return false; + } +}; + +/** Creates a font loader bound to `api` (pass `null` for a node-safe no-op loader). */ +export const createFontLoader = (api: FontLoadApi | null): FontLoader => { + const pending = new Map>(); + + return { + ensure: (font, onReady) => { + if (!api || safeCheck(api, font)) { + return; + } + const existing = pending.get(font); + if (existing) { + existing.then(onReady, () => {}); + return; + } + const promise = Promise.resolve(api.load(font)).then( + () => {}, + () => {} + ); + const tracked = promise.finally(() => pending.delete(font)); + pending.set(font, tracked); + tracked.then(onReady, () => {}); + }, + }; +}; + +/** Resolves the browser's `document.fonts` api, or `null` where it is unavailable (node, older browsers). */ +export const domFontLoadApi = (): FontLoadApi | null => { + if (typeof document === 'undefined') { + return null; + } + const fonts = (document as Document & { fonts?: FontLoadApi }).fonts; + return fonts && typeof fonts.check === 'function' && typeof fonts.load === 'function' ? fonts : null; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemand.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemand.test.ts new file mode 100644 index 00000000000..86385ffe66b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemand.test.ts @@ -0,0 +1,90 @@ +import type { CanvasDocumentContractV2, CanvasRasterLayerContractV2 } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import { calculateActiveFrameLayerIds } from './frameDemand'; + +const layer = (id: string, x: number, y: number, width = 100, height = 100): CanvasRasterLayerContractV2 => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height, imageName: `${id}.png`, width }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x, y }, + type: 'raster', +}); + +const document = (layers: CanvasRasterLayerContractV2[]): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 1_000, + layers, + selectedLayerId: null, + version: 2, + width: 1_000, +}); + +describe('calculateActiveFrameLayerIds', () => { + it('changes demand deterministically when panning reveals an offscreen layer', () => { + const doc = document([layer('left', 0, 0), layer('right', 500, 0)]); + + expect(calculateActiveFrameLayerIds({ document: doc, viewport: { height: 200, width: 200, x: 0, y: 0 } })).toEqual( + new Set(['left']) + ); + expect( + calculateActiveFrameLayerIds({ document: doc, viewport: { height: 200, width: 200, x: 450, y: 0 } }) + ).toEqual(new Set(['right'])); + }); + + it('uses live cache bounds for unflushed paint pixels', () => { + const paint = { ...layer('paint', 20, 20), source: { bitmap: null, type: 'paint' as const } }; + + expect( + calculateActiveFrameLayerIds({ + document: document([paint]), + liveCacheRects: new Map([['paint', { height: 30, width: 40, x: 0, y: 0 }]]), + viewport: { height: 100, width: 100, x: 0, y: 0 }, + }) + ).toEqual(new Set(['paint'])); + }); + + it('limits demand to isolated layers', () => { + const doc = document([layer('isolated', 0, 0), layer('covered', 0, 0)]); + + expect( + calculateActiveFrameLayerIds({ + document: doc, + isolationLayerIds: new Set(['isolated']), + viewport: { height: 200, width: 200, x: 0, y: 0 }, + }) + ).toEqual(new Set(['isolated'])); + }); + + it('uses a transient transform override before cache allocation', () => { + const moving = layer('moving', 500, 0); + + expect( + calculateActiveFrameLayerIds({ + document: document([moving]), + transformOverrides: new Map([['moving', { x: 20, y: 20 }]]), + viewport: { height: 200, width: 200, x: 0, y: 0 }, + }) + ).toEqual(new Set(['moving'])); + }); + + it('uses transformed corners for rotated-layer demand', () => { + const rotated = { + ...layer('rotated', 250, 0), + transform: { rotation: Math.PI / 2, scaleX: 1, scaleY: 1, x: 250, y: 0 }, + }; + + expect( + calculateActiveFrameLayerIds({ + document: document([rotated]), + viewport: { height: 120, width: 120, x: 140, y: 0 }, + }) + ).toEqual(new Set(['rotated'])); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemand.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemand.ts new file mode 100644 index 00000000000..96e29c979ce --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemand.ts @@ -0,0 +1,52 @@ +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { getSourceContentRect, renderableSourceOf } from '@workbench/canvas-engine/document/sources'; +import { fromTRS } from '@workbench/canvas-engine/math/mat2d'; +import { intersect, isEmpty, transformBounds, union } from '@workbench/canvas-engine/math/rect'; + +export interface FrameDemandInput { + readonly document: CanvasDocumentContractV2; + readonly isolationLayerIds?: ReadonlySet; + readonly liveCacheRects?: ReadonlyMap; + readonly transformOverrides?: ReadonlyMap< + string, + { x: number; y: number; scaleX?: number; scaleY?: number; rotation?: number } + >; + readonly viewport: Rect; +} + +/** Calculates the enabled raster caches whose transformed pixels intersect the next frame. */ +export const calculateActiveFrameLayerIds = ({ + document, + isolationLayerIds, + liveCacheRects, + transformOverrides, + viewport, +}: FrameDemandInput): Set => { + const active = new Set(); + for (const layer of document.layers) { + if (!layer.isEnabled || !renderableSourceOf(layer) || (isolationLayerIds && !isolationLayerIds.has(layer.id))) { + continue; + } + const sourceRect = getSourceContentRect(layer, document); + const liveRect = liveCacheRects?.get(layer.id); + const localRect = + liveRect && !isEmpty(liveRect) ? (isEmpty(sourceRect) ? liveRect : union(sourceRect, liveRect)) : sourceRect; + const override = transformOverrides?.get(layer.id); + const transform = override + ? { + rotation: override.rotation ?? layer.transform.rotation, + scaleX: override.scaleX ?? layer.transform.scaleX, + scaleY: override.scaleY ?? layer.transform.scaleY, + x: override.x, + y: override.y, + } + : layer.transform; + const matrix = fromTRS({ x: transform.x, y: transform.y }, transform.rotation, transform.scaleX, transform.scaleY); + if (intersect(transformBounds(matrix, localRect), viewport)) { + active.add(layer.id); + } + } + return active; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemandBudget.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemandBudget.test.ts new file mode 100644 index 00000000000..fe6deb56e4e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemandBudget.test.ts @@ -0,0 +1,61 @@ +import type { CanvasDocumentContractV2, CanvasRasterLayerContractV2 } from '@workbench/types'; + +import { describe, expect, it, vi } from 'vitest'; + +import { calculateActiveFrameLayerIds } from './frameDemand'; +import { createLayerCacheStore } from './layerCache'; +import { createTestStubRasterBackend } from './raster.testStub'; + +const layer = (id: string, x: number): CanvasRasterLayerContractV2 => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height: 100, imageName: `${id}.png`, width: 100 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x, y: 0 }, + type: 'raster', +}); + +describe('frame demand cache allocation', () => { + it('allocates on reveal, evicts offscreen LRU, and re-allocates deterministically on return', () => { + const stub = createTestStubRasterBackend(); + const createSurface = vi.fn(stub.createSurface); + const caches = createLayerCacheStore({ ...stub, createSurface }); + const document: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 1_000, + layers: [layer('left', 0), layer('right', 500)], + selectedLayerId: null, + version: 2, + width: 1_000, + }; + const allocateDemand = (x: number): Set => { + const active = calculateActiveFrameLayerIds({ + document, + viewport: { height: 200, width: 200, x, y: 0 }, + }); + for (const id of active) { + caches.getOrCreate(id, 100, 100); + } + caches.evictHidden(active, 40_000); + return active; + }; + + expect(allocateDemand(0)).toEqual(new Set(['left'])); + expect(createSurface).toHaveBeenCalledTimes(1); + expect(caches.byteSize()).toBe(40_000); + + expect(allocateDemand(450)).toEqual(new Set(['right'])); + expect(createSurface).toHaveBeenCalledTimes(2); + expect(caches.peek('left')).toBeUndefined(); + expect(caches.byteSize()).toBe(40_000); + + expect(allocateDemand(0)).toEqual(new Set(['left'])); + expect(createSurface).toHaveBeenCalledTimes(3); + expect(caches.peek('right')).toBeUndefined(); + expect(caches.byteSize()).toBe(40_000); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/layerCache.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/layerCache.test.ts new file mode 100644 index 00000000000..9f15ec753e8 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/layerCache.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { StubRasterSurface } from './raster.testStub'; + +import { createLayerCacheStore, DEFAULT_CACHE_BUDGET_BYTES } from './layerCache'; +import { createTestStubRasterBackend } from './raster.testStub'; + +describe('createLayerCacheStore', () => { + it('notifies version changes for pixel publication, invalidation, replacement, growth, and deletion', () => { + const backend = createTestStubRasterBackend(); + const onVersionChange = vi.fn(); + const store = createLayerCacheStore(backend, { onVersionChange }); + + store.getOrCreate('layer-1', 10, 10); + expect(onVersionChange).not.toHaveBeenCalled(); + + store.publishPixels('layer-1'); + store.growToRect('layer-1', { height: 20, width: 20, x: 0, y: 0 }); + store.invalidate('layer-1'); + store.installReplacement( + store.prepareReplacement('layer-1', { height: 5, width: 5, x: 0, y: 0 }, backend.createSurface(5, 5)) + ); + store.delete('layer-1'); + + expect(onVersionChange).toHaveBeenCalledTimes(5); + expect(onVersionChange).toHaveBeenCalledWith('layer-1'); + }); + + it('does not notify for ordinary fresh or stale allocation', () => { + const onVersionChange = vi.fn(); + const store = createLayerCacheStore(createTestStubRasterBackend(), { onVersionChange }); + + store.getOrCreate('origin', 10, 10); + store.getOrCreateRect('rect', { height: 10, width: 10, x: 2, y: 3 }); + store.growToRect('grown', { height: 10, width: 10, x: 2, y: 3 }); + + expect(onVersionChange).not.toHaveBeenCalled(); + }); + + it('returns a stable entry identity for the same layer + size', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const a = store.getOrCreate('layer-1', 100, 50); + const b = store.getOrCreate('layer-1', 100, 50); + expect(b).toBe(a); + expect(store.get('layer-1')).toBe(a); + }); + + it('marks new allocations as never published', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + + expect(store.getOrCreate('origin', 10, 10).hasPublishedPixels).toBe(false); + expect(store.getOrCreateRect('rect', { height: 10, width: 10, x: 2, y: 3 }).hasPublishedPixels).toBe(false); + expect(store.growToRect('grown', { height: 10, width: 10, x: 2, y: 3 }).hasPublishedPixels).toBe(false); + }); + + it('marks installed raster replacements as published', () => { + const backend = createTestStubRasterBackend(); + const store = createLayerCacheStore(backend); + const prepared = store.prepareReplacement( + 'layer-1', + { height: 10, width: 10, x: 0, y: 0 }, + backend.createSurface(10, 10) + ); + + expect(store.installReplacement(prepared).hasPublishedPixels).toBe(true); + }); + + it('preserves published pixels through invalidation so stale previews remain drawable', () => { + const backend = createTestStubRasterBackend(); + const store = createLayerCacheStore(backend); + const entry = store.installReplacement( + store.prepareReplacement('layer-1', { height: 10, width: 10, x: 0, y: 0 }, backend.createSurface(10, 10)) + ); + + store.invalidate('layer-1'); + + expect(entry.stale).toBe(true); + expect(entry.hasPublishedPixels).toBe(true); + }); + + it('resets publication readiness when a resize destroys the old pixels', () => { + const backend = createTestStubRasterBackend(); + const store = createLayerCacheStore(backend); + const entry = store.installReplacement( + store.prepareReplacement('layer-1', { height: 10, width: 10, x: 0, y: 0 }, backend.createSurface(10, 10)) + ); + + store.getOrCreate('layer-1', 20, 20); + + expect(entry.hasPublishedPixels).toBe(false); + }); + + it('resizes the surface (and marks stale) when the requested size changes', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.getOrCreate('layer-1', 100, 50); + entry.stale = false; + const resized = store.getOrCreate('layer-1', 200, 80); + expect(resized).toBe(entry); + expect(resized.surface.width).toBe(200); + expect(resized.surface.height).toBe(80); + expect(resized.stale).toBe(true); + }); + + it('invalidate bumps the version and marks the cache stale', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.getOrCreate('layer-1', 10, 10); + entry.stale = false; + expect(store.version('layer-1')).toBe(0); + + store.invalidate('layer-1'); + expect(store.version('layer-1')).toBe(1); + expect(entry.version).toBe(1); + expect(entry.stale).toBe(true); + + store.invalidate('layer-1'); + expect(store.version('layer-1')).toBe(2); + }); + + it('version is 0 for an unknown layer', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + expect(store.version('nope')).toBe(0); + }); + + it('a recreated entry (after delete) resumes ABOVE the old version, never resetting to 0', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const first = store.getOrCreate('a', 10, 10); + store.invalidate('a'); + store.invalidate('a'); + expect(first.version).toBe(2); + + // Delete + recreate (undo→redo / transform bake / merge / evict→re-show): the + // fresh entry must start above 2 so version-keyed caches (adjusted surface, + // thumbnails) can't mistake its new pixels for a version they already hold. + store.delete('a'); + const recreated = store.getOrCreate('a', 10, 10); + expect(recreated.version).toBeGreaterThan(2); + }); + + it('an evicted-then-re-shown entry also resumes above its pre-eviction version', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + store.getOrCreate('hidden', 100, 100); + store.invalidate('hidden'); // version 1 + const budget = 100 * 100 * 4; // room for one surface + store.getOrCreate('visible', 100, 100); + const evicted = store.evictHidden(['visible'], budget); + expect(evicted).toContain('hidden'); + + const reshown = store.getOrCreate('hidden', 100, 100); + expect(reshown.version).toBeGreaterThan(1); + }); + + it('byteSize sums w*h*4 across all cache surfaces', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + store.getOrCreate('a', 100, 100); // 40_000 + store.getOrCreate('b', 10, 10); // 400 + expect(store.byteSize()).toBe(100 * 100 * 4 + 10 * 10 * 4); + }); + + it('delete drops a cache entry', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + store.getOrCreate('a', 10, 10); + store.delete('a'); + expect(store.get('a')).toBeUndefined(); + expect(store.byteSize()).toBe(0); + }); + + it('evictHidden does nothing when already within budget', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + store.getOrCreate('a', 10, 10); + const evicted = store.evictHidden([], DEFAULT_CACHE_BUDGET_BYTES); + expect(evicted).toEqual([]); + expect(store.get('a')).toBeDefined(); + }); + + it('evictHidden evicts least-recently-used hidden caches until within budget, never visible ones', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + // Each surface is 100*100*4 = 40_000 bytes. + store.getOrCreate('old-hidden', 100, 100); + store.getOrCreate('visible', 100, 100); + store.getOrCreate('new-hidden', 100, 100); + // Touch 'new-hidden' so 'old-hidden' is the LRU among hidden entries. + store.get('new-hidden'); + + // Budget allows exactly two surfaces; three exist -> one hidden must go. + const budget = 100 * 100 * 4 * 2; + const evicted = store.evictHidden(['visible'], budget); + + expect(evicted).toEqual(['old-hidden']); + expect(store.get('old-hidden')).toBeUndefined(); + expect(store.get('visible')).toBeDefined(); + expect(store.get('new-hidden')).toBeDefined(); + expect(store.byteSize()).toBeLessThanOrEqual(budget); + }); + + it('evictHidden will not evict visible caches even if still over budget', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + store.getOrCreate('visible', 100, 100); + const evicted = store.evictHidden(['visible'], 1); + expect(evicted).toEqual([]); + expect(store.get('visible')).toBeDefined(); + }); + + it('dispose clears caches', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + store.getOrCreate('a', 10, 10); + + store.dispose(); + expect(store.byteSize()).toBe(0); + }); +}); + +describe('getOrCreateRect (content-sized, off-origin placement)', () => { + it('creates a new surface placed at an off-origin (negative) layer-local rect', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.getOrCreateRect('L', { height: 40, width: 60, x: -15, y: -25 }); + expect(entry.rect).toEqual({ height: 40, width: 60, x: -15, y: -25 }); + expect(entry.surface.width).toBe(60); + expect(entry.surface.height).toBe(40); + expect(entry.stale).toBe(true); + }); + + it('NEVER resizes or re-places an existing entry (a grown paint cache must survive)', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.getOrCreateRect('L', { height: 40, width: 60, x: 5, y: 5 }); + entry.stale = false; + // A later request with the (smaller/different) contract rect returns the + // live entry untouched: sizing belongs to the rasterizer/grow paths. + const again = store.getOrCreateRect('L', { height: 10, width: 10, x: 0, y: 0 }); + expect(again).toBe(entry); + expect(again.rect).toEqual({ height: 40, width: 60, x: 5, y: 5 }); + expect(again.surface.width).toBe(60); + expect(again.stale).toBe(false); + }); + + it('supports a zero-rect entry (brand-new empty paint layer)', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.getOrCreateRect('L', { height: 0, width: 0, x: 0, y: 0 }); + expect(entry.rect).toEqual({ height: 0, width: 0, x: 0, y: 0 }); + expect(entry.surface.width).toBe(0); + expect(entry.surface.height).toBe(0); + }); +}); + +describe('growToRect (paint caches grow with strokes)', () => { + it('creates a fresh non-stale entry at the rounded target rect when none exists', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.growToRect('L', { height: 19.2, width: 10.6, x: 3.4, y: -2.7 }); + expect(entry.rect).toEqual({ height: 19, width: 11, x: 3, y: -3 }); + expect(entry.stale).toBe(false); + }); + + it('grows to the UNION of the current extent and the request, preserving old pixels at their shifted origin', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.growToRect('L', { height: 20, width: 20, x: 10, y: 10 }); + const surfaceBefore = entry.surface; + + const grown = store.growToRect('L', { height: 20, width: 20, x: -10, y: -10 }); + expect(grown).toBe(entry); + // Union of [10,30)² and [-10,10)² = [-10,30)² → 40×40 at (-10,-10). + expect(grown.rect).toEqual({ height: 40, width: 40, x: -10, y: -10 }); + // Resized in place: the surface identity is preserved (open sessions hold it). + expect(grown.surface).toBe(surfaceBefore); + expect(grown.surface.width).toBe(40); + expect(grown.surface.height).toBe(40); + + // The old pixels were snapshotted before the (clearing) resize and blitted + // back at their layer-local position within the grown surface: old origin + // (10,10) minus new origin (-10,-10) = surface (20,20). + const log = (grown.surface as StubRasterSurface).callLog; + const snapshot = log.find((e) => e.op === 'getImageData'); + expect(snapshot?.args).toEqual([0, 0, 20, 20]); + const resizeIndex = log.findIndex((e) => e.op === 'resize'); + const putIndex = log.findIndex((e) => e.op === 'putImageData'); + expect(resizeIndex).toBeGreaterThan(-1); + expect(putIndex).toBeGreaterThan(resizeIndex); + expect(log[putIndex]?.args.slice(1)).toEqual([20, 20]); + }); + + it('is a no-op (no resize, no blit) when the current extent already covers the request', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.growToRect('L', { height: 100, width: 100, x: 0, y: 0 }); + const logLength = (entry.surface as StubRasterSurface).callLog.length; + + const again = store.growToRect('L', { height: 10, width: 10, x: 20, y: 20 }); + expect(again).toBe(entry); + expect(again.rect).toEqual({ height: 100, width: 100, x: 0, y: 0 }); + expect((again.surface as StubRasterSurface).callLog.length).toBe(logLength); + }); + + it('adopts the target rect from an EMPTY extent without snapshotting stale pixels', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const empty = store.getOrCreateRect('L', { height: 0, width: 0, x: 0, y: 0 }); + empty.stale = false; + + const grown = store.growToRect('L', { height: 30, width: 30, x: 50, y: 60 }); + expect(grown).toBe(empty); + // No union with the empty rect's (0,0) origin: the first stroke's bounds + // become the extent verbatim (no needless 0,0-anchored surface). + expect(grown.rect).toEqual({ height: 30, width: 30, x: 50, y: 60 }); + const log = (grown.surface as StubRasterSurface).callLog; + expect(log.some((e) => e.op === 'getImageData')).toBe(false); + expect(log.some((e) => e.op === 'putImageData')).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/layerCache.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/layerCache.ts new file mode 100644 index 00000000000..ad85bc16cd9 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/layerCache.ts @@ -0,0 +1,415 @@ +/** + * Per-layer raster cache store. + * + * Each layer's rendered pixels are cached on their own {@link RasterSurface} + * so the compositor can redraw the document without re-rasterizing every + * layer each frame. Caches are always re-rasterizable, so eviction is safe: + * a hidden layer's cache can be dropped to stay under a memory budget and + * rebuilt on demand when it becomes visible again. + * + * All surface allocation flows through the injected {@link RasterBackend} + * seam, so the store runs unchanged in node tests. Zero React, zero + * import-time side effects. + */ + +import type { Rect } from '@workbench/canvas-engine/types'; + +import type { RasterBackend, RasterSurface } from './raster'; + +/** Default cache budget: ~512 MB of surface pixels before hidden caches are evicted. */ +export const DEFAULT_CACHE_BUDGET_BYTES = 512 * 1024 * 1024; + +const BYTES_PER_PIXEL = 4; + +/** A single layer's cache entry. */ +export interface LayerCacheEntry { + readonly layerId: string; + /** The backing surface holding the layer's rasterized pixels. */ + surface: RasterSurface; + /** True only after real pixels have been published into this allocation. */ + hasPublishedPixels: boolean; + /** + * The surface's content bounds in the layer's LOCAL coordinate space. The + * surface holds `rect.width`×`rect.height` pixels; surface pixel `(sx, sy)` + * maps to layer-local `(rect.x + sx, rect.y + sy)`. The origin can be negative + * (a paint layer grown up/left of its start). The compositor draws the surface + * at `transform × rect.origin`. An empty rect (0 width/height) marks an empty + * layer (a brand-new or cleared paint layer) — safe to skip everywhere. + */ + rect: Rect; + /** Bumped every time the cache is invalidated; thumbnails/subscribers watch this. */ + version: number; + /** True when the cached pixels are known to be out of date and need re-rasterizing. */ + stale: boolean; + /** Monotonic access tick, used to order LRU eviction. */ + lastUsed: number; +} + +/** + * A fully-rasterized cache replacement that has not been published to the live + * cache map yet. Preparation may allocate/draw and therefore may fail; install + * performs only in-memory version/map bookkeeping. + */ +export interface PreparedLayerCacheReplacement { + readonly layerId: string; + readonly rect: Rect; + readonly surface: RasterSurface; +} + +export interface LayerCacheStoreOptions { + /** Called after a cache identity or pixels change; fresh allocations stay silent. */ + onVersionChange?(layerId: string): void; +} + +/** The imperative store returned by {@link createLayerCacheStore}. */ +export interface LayerCacheStore { + /** Returns an entry without changing LRU order. */ + peek(layerId: string): LayerCacheEntry | undefined; + /** Returns the existing cache entry for a layer, or `undefined`. Touches LRU order. */ + get(layerId: string): LayerCacheEntry | undefined; + /** + * Returns the cache entry for a layer, creating (or resizing) its surface to + * `width`x`height` at the LOCAL origin `(0, 0)`. The returned entry identity is + * stable across calls that don't change the size, so callers can hold onto it + * between frames. Use this for origin-anchored layers (image / shape / text / + * gradient); paint caches, which can grow off-origin, use {@link getOrCreateRect} + * / {@link growToRect}. + */ + getOrCreate(layerId: string, width: number, height: number): LayerCacheEntry; + /** + * Like {@link getOrCreate} but places the surface at an arbitrary layer-local + * `rect` origin (which may be negative). Creates the entry when absent; when it + * already exists the surface is left untouched (the caller — a rasterizer or a + * growth op — owns resizing), only ensuring existence. Used for paint layers + * whose content rect is not origin-anchored. + */ + getOrCreateRect(layerId: string, rect: Rect): LayerCacheEntry; + /** + * Grows (never shrinks) a layer's cache to cover `rect` in layer-local space, + * preserving the existing pixels at their new offset. A no-op when `rect` is + * already within the current extent. Creates the entry (surface sized to `rect`) + * when absent. Returns the entry. + */ + growToRect(layerId: string, rect: Rect): LayerCacheEntry; + /** Clones `pixels` into a detached replacement without mutating the live cache. */ + prepareReplacement(layerId: string, rect: Rect, pixels: RasterSurface): PreparedLayerCacheReplacement; + /** Publishes a detached replacement without allocating, resizing, or drawing. */ + installReplacement(prepared: PreparedLayerCacheReplacement): LayerCacheEntry; + /** Marks directly-written pixels current, bumps the version, and notifies observers. */ + publishPixels(layerId: string): LayerCacheEntry | undefined; + /** Marks a layer's cache stale and bumps its `version`. */ + invalidate(layerId: string): void; + /** Drops a layer's cache entry entirely. */ + delete(layerId: string): void; + /** The current `version` for a layer (0 if it has no cache yet). */ + version(layerId: string): number; + /** Total bytes held across all cache surfaces (w*h*4 each). */ + byteSize(): number; + /** + * Evicts hidden-layer caches (those whose id is not in `visibleIds`) in + * least-recently-used order until the total {@link byteSize} is within + * `budgetBytes`. Visible layers are never evicted. Returns the evicted ids. + */ + evictHidden(visibleIds: Iterable, budgetBytes?: number): string[]; + /** Releases every cache entry. Cache surfaces are GC'd with the store. */ + dispose(): void; +} + +const surfaceBytes = (surface: RasterSurface): number => surface.width * surface.height * BYTES_PER_PIXEL; + +/** Creates a per-layer raster cache store backed by the given {@link RasterBackend}. */ +export const createLayerCacheStore = ( + backend: RasterBackend, + options: LayerCacheStoreOptions = {} +): LayerCacheStore => { + const entries = new Map(); + // Per-id version FLOOR: the highest version each layer id has ever reached, + // retained across delete/recreate (delete, LRU eviction). A recreated entry + // (undo→redo, transform bake, merge, evict→re-show) starts ABOVE this floor + // rather than resetting to 0 — so version-keyed dependents (the adjusted-surface + // cache, thumbnail state) never mistake fresh pixels for a stale version they + // already have cached. Only ever grows; a plain number per id (negligible). + const versionFloors = new Map(); + let tick = 0; + + const notifyVersionChange = (layerId: string): void => { + try { + options.onVersionChange?.(layerId); + } catch { + // Cache mutation is already complete; observers cannot roll it back. + } + }; + + const touch = (entry: LayerCacheEntry): void => { + tick += 1; + entry.lastUsed = tick; + }; + + /** The version a freshly-created entry for `layerId` must start at (monotonic). */ + const initialVersion = (layerId: string): number => { + const floor = versionFloors.get(layerId); + return floor === undefined ? 0 : floor + 1; + }; + + /** Records a to-be-dropped entry's version as the id's floor, so a recreate exceeds it. */ + const rememberFloor = (entry: LayerCacheEntry): void => { + const prev = versionFloors.get(entry.layerId) ?? -1; + if (entry.version > prev) { + versionFloors.set(entry.layerId, entry.version); + } + }; + + const get = (layerId: string): LayerCacheEntry | undefined => { + const entry = entries.get(layerId); + if (entry) { + touch(entry); + } + return entry; + }; + + const peek = (layerId: string): LayerCacheEntry | undefined => entries.get(layerId); + + const getOrCreate = (layerId: string, width: number, height: number): LayerCacheEntry => { + const existing = entries.get(layerId); + if (existing) { + const changed = + existing.surface.width !== width || + existing.surface.height !== height || + existing.rect.x !== 0 || + existing.rect.y !== 0; + const hadPublishedPixels = existing.hasPublishedPixels; + if (existing.surface.width !== width || existing.surface.height !== height) { + existing.surface.resize(width, height); + existing.hasPublishedPixels = false; + existing.stale = true; + } + // Origin-anchored: this variant always places the surface at (0, 0). + existing.rect = { height, width, x: 0, y: 0 }; + touch(existing); + if (changed && hadPublishedPixels) { + existing.version += 1; + notifyVersionChange(layerId); + } + return existing; + } + const entry: LayerCacheEntry = { + hasPublishedPixels: false, + lastUsed: 0, + layerId, + rect: { height, width, x: 0, y: 0 }, + stale: true, + surface: backend.createSurface(width, height), + version: initialVersion(layerId), + }; + touch(entry); + entries.set(layerId, entry); + return entry; + }; + + const getOrCreateRect = (layerId: string, rect: Rect): LayerCacheEntry => { + const existing = entries.get(layerId); + if (existing) { + touch(existing); + return existing; + } + const width = Math.max(0, Math.round(rect.width)); + const height = Math.max(0, Math.round(rect.height)); + const entry: LayerCacheEntry = { + hasPublishedPixels: false, + lastUsed: 0, + layerId, + rect: { height, width, x: rect.x, y: rect.y }, + stale: true, + surface: backend.createSurface(width, height), + version: initialVersion(layerId), + }; + touch(entry); + entries.set(layerId, entry); + return entry; + }; + + const growToRect = (layerId: string, rect: Rect): LayerCacheEntry => { + const existing = entries.get(layerId); + const targetRect: Rect = { + height: Math.max(0, Math.round(rect.height)), + width: Math.max(0, Math.round(rect.width)), + x: Math.round(rect.x), + y: Math.round(rect.y), + }; + if (!existing) { + const entry: LayerCacheEntry = { + hasPublishedPixels: false, + lastUsed: 0, + layerId, + rect: targetRect, + stale: false, + surface: backend.createSurface(targetRect.width, targetRect.height), + version: initialVersion(layerId), + }; + touch(entry); + entries.set(layerId, entry); + return entry; + } + const cur = existing.rect; + const curEmpty = cur.width <= 0 || cur.height <= 0; + // Union of the current extent and the requested rect (in layer-local space). + const minX = curEmpty ? targetRect.x : Math.min(cur.x, targetRect.x); + const minY = curEmpty ? targetRect.y : Math.min(cur.y, targetRect.y); + const maxX = curEmpty + ? targetRect.x + targetRect.width + : Math.max(cur.x + cur.width, targetRect.x + targetRect.width); + const maxY = curEmpty + ? targetRect.y + targetRect.height + : Math.max(cur.y + cur.height, targetRect.y + targetRect.height); + const newRect: Rect = { height: maxY - minY, width: maxX - minX, x: minX, y: minY }; + if (newRect.x === cur.x && newRect.y === cur.y && newRect.width === cur.width && newRect.height === cur.height) { + // Already covers the request — no realloc. + touch(existing); + return existing; + } + // Snapshot the old pixels BEFORE resizing (resize clears the backing canvas), + // then blit them back at their new offset within the grown surface. Skip the + // copy when the old extent was empty (nothing to preserve). + const surface = existing.surface; + let snapshot: ImageData | null = null; + if (!curEmpty && cur.width > 0 && cur.height > 0) { + snapshot = surface.ctx.getImageData(0, 0, cur.width, cur.height); + } + surface.resize(newRect.width, newRect.height); + if (snapshot) { + surface.ctx.putImageData(snapshot, cur.x - newRect.x, cur.y - newRect.y); + } + existing.rect = newRect; + touch(existing); + if (existing.hasPublishedPixels) { + existing.version += 1; + notifyVersionChange(layerId); + } + return existing; + }; + + const prepareReplacement = (layerId: string, rect: Rect, pixels: RasterSurface): PreparedLayerCacheReplacement => { + const normalizedRect: Rect = { + height: Math.max(0, Math.round(rect.height)), + width: Math.max(0, Math.round(rect.width)), + x: rect.x, + y: rect.y, + }; + const surface = backend.createSurface(normalizedRect.width, normalizedRect.height); + if (normalizedRect.width > 0 && normalizedRect.height > 0) { + surface.ctx.clearRect(0, 0, normalizedRect.width, normalizedRect.height); + surface.ctx.drawImage(pixels.canvas, 0, 0); + } + return { layerId, rect: normalizedRect, surface }; + }; + + const installReplacement = (prepared: PreparedLayerCacheReplacement): LayerCacheEntry => { + const existing = entries.get(prepared.layerId); + if (existing) { + rememberFloor(existing); + } + const entry: LayerCacheEntry = { + hasPublishedPixels: true, + lastUsed: 0, + layerId: prepared.layerId, + rect: prepared.rect, + stale: false, + surface: prepared.surface, + // Directly-published pixels receive the same extra version bump that the + // old create-then-notify path applied, while remaining monotonic on swap. + version: initialVersion(prepared.layerId) + 1, + }; + touch(entry); + entries.set(prepared.layerId, entry); + notifyVersionChange(prepared.layerId); + return entry; + }; + + const publishPixels = (layerId: string): LayerCacheEntry | undefined => { + const entry = entries.get(layerId); + if (!entry) { + return undefined; + } + entry.hasPublishedPixels = true; + entry.stale = false; + entry.version += 1; + notifyVersionChange(layerId); + return entry; + }; + + const invalidate = (layerId: string): void => { + const entry = entries.get(layerId); + if (entry) { + entry.version += 1; + entry.stale = true; + notifyVersionChange(layerId); + } + }; + + const del = (layerId: string): void => { + const entry = entries.get(layerId); + if (entry) { + rememberFloor(entry); + entries.delete(layerId); + notifyVersionChange(layerId); + } + }; + + const version = (layerId: string): number => entries.get(layerId)?.version ?? 0; + + const byteSize = (): number => { + let total = 0; + for (const entry of entries.values()) { + total += surfaceBytes(entry.surface); + } + return total; + }; + + const evictHidden = (visibleIds: Iterable, budgetBytes: number = DEFAULT_CACHE_BUDGET_BYTES): string[] => { + const visible = new Set(visibleIds); + const evicted: string[] = []; + let total = byteSize(); + if (total <= budgetBytes) { + return evicted; + } + // Hidden entries, least-recently-used first. + const candidates = [...entries.values()] + .filter((entry) => !visible.has(entry.layerId)) + .sort((a, b) => a.lastUsed - b.lastUsed); + for (const entry of candidates) { + if (total <= budgetBytes) { + break; + } + // Retain the version floor so a re-shown (re-rasterized) layer resumes ABOVE + // its pre-eviction version, not from 0 — otherwise the adjusted-surface / + // thumbnail caches would serve stale pixels keyed to the recycled version. + rememberFloor(entry); + entries.delete(entry.layerId); + evicted.push(entry.layerId); + total -= surfaceBytes(entry.surface); + notifyVersionChange(entry.layerId); + } + return evicted; + }; + + const dispose = (): void => { + entries.clear(); + }; + + return { + byteSize, + delete: del, + dispose, + evictHidden, + get, + getOrCreate, + getOrCreateRect, + growToRect, + installReplacement, + invalidate, + peek, + prepareReplacement, + publishPixels, + version, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/maskFill.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/maskFill.test.ts new file mode 100644 index 00000000000..889ed647e2e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/maskFill.test.ts @@ -0,0 +1,93 @@ +import type { CanvasMaskFillContract } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import type { RasterCallLogEntry, StubRasterSurface } from './raster.testStub'; + +import { colorizeMask, createMaskPatternTile } from './maskFill'; +import { createTestStubRasterBackend } from './raster.testStub'; + +/** Values recorded for a set-property (e.g. `strokeStyle`), in order. */ +const setValues = (log: RasterCallLogEntry[], prop: string): unknown[] => + log.filter((e) => e.op === 'set' && e.args[0] === prop).map((e) => e.args[1]); + +const countOp = (log: RasterCallLogEntry[], op: string): number => log.filter((e) => e.op === op).length; + +describe('createMaskPatternTile', () => { + it('returns null for the solid style (no pattern — filled directly)', () => { + const backend = createTestStubRasterBackend(); + expect(createMaskPatternTile(backend, 'solid', '#ff0000')).toBeNull(); + }); + + it('builds a colour-specific tile per non-solid style, stroking in the fill colour', () => { + const backend = createTestStubRasterBackend(); + for (const style of ['grid', 'crosshatch', 'diagonal', 'horizontal', 'vertical'] as const) { + const tile = createMaskPatternTile(backend, style, '#00ff88') as StubRasterSurface; + expect(tile).not.toBeNull(); + // Lines are drawn (stroke) in the fill colour. + expect(setValues(tile.callLog, 'strokeStyle')).toContain('#00ff88'); + expect(countOp(tile.callLog, 'stroke')).toBeGreaterThan(0); + } + }); + + it('sizes tiles to the legacy specs (diagonal/crosshatch 6px, grid 12px, h/v 9px)', () => { + const backend = createTestStubRasterBackend(); + const size = (style: CanvasMaskFillContract['style']): number => + (createMaskPatternTile(backend, style, '#fff') as StubRasterSurface).width; + expect(size('diagonal')).toBe(6); + expect(size('crosshatch')).toBe(6); + expect(size('grid')).toBe(12); + expect(size('horizontal')).toBe(9); + expect(size('vertical')).toBe(9); + }); + + it('draws more lines for crosshatch (both directions) than diagonal (one direction)', () => { + const backend = createTestStubRasterBackend(); + const diagonal = createMaskPatternTile(backend, 'diagonal', '#fff') as StubRasterSurface; + const crosshatch = createMaskPatternTile(backend, 'crosshatch', '#fff') as StubRasterSurface; + expect(countOp(crosshatch.callLog, 'stroke')).toBeGreaterThan(countOp(diagonal.callLog, 'stroke')); + }); + + it('horizontal draws only horizontal lines, vertical only vertical (equal counts, disjoint geometry)', () => { + const backend = createTestStubRasterBackend(); + const horizontal = createMaskPatternTile(backend, 'horizontal', '#fff') as StubRasterSurface; + const vertical = createMaskPatternTile(backend, 'vertical', '#fff') as StubRasterSurface; + // Same 9px tile, same spacing → same number of strokes. + expect(countOp(horizontal.callLog, 'stroke')).toBe(countOp(vertical.callLog, 'stroke')); + }); +}); + +describe('colorizeMask', () => { + const fill = (style: CanvasMaskFillContract['style'], color = '#ff0000'): CanvasMaskFillContract => ({ + color, + style, + }); + + it('draws the mask stencil then fills the colour with source-in (solid)', () => { + const backend = createTestStubRasterBackend(); + const mask = backend.createSurface(10, 10); + const out = colorizeMask(backend, mask, 10, 10, fill('solid'), null) as StubRasterSurface; + + const ops = out.callLog; + const drawIdx = ops.findIndex((e) => e.op === 'drawImage'); + const sourceInIdx = ops.findIndex( + (e) => e.op === 'set' && e.args[0] === 'globalCompositeOperation' && e.args[1] === 'source-in' + ); + const fillRectIdx = ops.findIndex((e) => e.op === 'fillRect'); + // Order: stencil blit → switch to source-in → fill the colour. + expect(drawIdx).toBeGreaterThanOrEqual(0); + expect(sourceInIdx).toBeGreaterThan(drawIdx); + expect(fillRectIdx).toBeGreaterThan(sourceInIdx); + expect(setValues(ops, 'fillStyle')).toContain('#ff0000'); + }); + + it('uses the pattern tile as the fill when one is provided (pattern style)', () => { + const backend = createTestStubRasterBackend(); + const mask = backend.createSurface(8, 8); + const tile = createMaskPatternTile(backend, 'diagonal', '#ff0000'); + const out = colorizeMask(backend, mask, 8, 8, fill('diagonal'), tile) as StubRasterSurface; + // A repeat pattern was created and used as the source-in fill. + expect(countOp(out.callLog, 'createPattern')).toBe(1); + expect(out.callLog.some((e) => e.op === 'createPattern' && e.args[1] === 'repeat')).toBe(true); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/maskFill.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/maskFill.ts new file mode 100644 index 00000000000..9967c413ee1 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/maskFill.ts @@ -0,0 +1,157 @@ +/** + * Mask fill rendering: turns a mask layer's alpha stencil into a tinted, + * translucent overlay coloured by its {@link CanvasMaskFillContract}. + * + * Mirrors legacy (`features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts`): + * the mask objects are drawn as an opaque alpha stencil, then a fill (a solid + * colour, or one of five line PATTERNS) is composited over them with + * `globalCompositeOperation: 'source-in'` so the colour/pattern shows ONLY where + * the mask is painted. The whole overlay is drawn above all raster/control + * content at the layer's opacity (legacy default `1`). + * + * Pattern tiles are small, colour-specific, cached surfaces built through the + * {@link RasterBackend} seam (like the transparency checkerboard). Legacy anchors + * its pattern to the screen; here the colorized surface is drawn through the + * layer transform, so the pattern is OBJECT-anchored (it scales/pans with the + * mask content) — a deliberate, documented simplification of the screen-anchored + * legacy behaviour that keeps the whole pipeline node-testable through the seam. + * + * Zero React, zero import-time side effects. + */ + +import type { CanvasMaskFillContract } from '@workbench/types'; + +import type { RasterBackend, RasterSurface } from './raster'; + +/** Line specs (tile size, line spacing, stroke width) for each non-solid fill style. */ +const PATTERN_SPECS: Record< + Exclude, + { size: number; spacing: number; width: number } +> = { + // 6px tile, two parallel 45° lines 3px apart (legacy `pattern-diagonal.svg`). + diagonal: { size: 6, spacing: 3, width: 1.3 }, + // 6px tile, 45° lines in BOTH directions (legacy `pattern-crosshatch.svg`). + crosshatch: { size: 6, spacing: 3, width: 1.2 }, + // 12px tile, 3 vertical + 3 horizontal lines 4px apart (legacy `pattern-grid.svg`). + grid: { size: 12, spacing: 4, width: 1 }, + // 9px tile, 3 horizontal lines 3px apart (legacy `pattern-horizontal.svg`). + horizontal: { size: 9, spacing: 3, width: 1 }, + // 9px tile, 3 vertical lines 3px apart (legacy `pattern-vertical.svg`). + vertical: { size: 9, spacing: 3, width: 1 }, +}; + +/** Draws the anti-diagonal (`x + y = c`) or main-diagonal (`x - y = c`) line family across the tile. */ +const drawDiagonalLines = ( + ctx: RasterSurface['ctx'], + size: number, + spacing: number, + direction: 'anti' | 'main' +): void => { + // Sweep `c` well beyond the tile so every crossing line is drawn; the surface + // clips to its bounds. `spacing` divides `size`, so the family tiles seamlessly. + for (let c = -size; c <= 2 * size; c += spacing) { + ctx.beginPath(); + if (direction === 'anti') { + // x + y = c: a long segment crossing the tile. + ctx.moveTo(c + size, -size); + ctx.lineTo(c - 2 * size, 2 * size); + } else { + // x - y = c. + ctx.moveTo(c - size, -size); + ctx.lineTo(c + 2 * size, 2 * size); + } + ctx.stroke(); + } +}; + +/** + * Builds a colour-specific repeat tile for a non-solid fill `style`, or returns + * `null` for `'solid'` (no pattern — the colour is filled directly). The tile is + * drawn in `color` through the backend seam. + */ +export const createMaskPatternTile = ( + backend: RasterBackend, + style: CanvasMaskFillContract['style'], + color: string +): RasterSurface | null => { + if (style === 'solid') { + return null; + } + const spec = PATTERN_SPECS[style]; + const tile = backend.createSurface(spec.size, spec.size); + const ctx = tile.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, spec.size, spec.size); + ctx.strokeStyle = color; + ctx.lineWidth = spec.width; + + switch (style) { + case 'diagonal': + drawDiagonalLines(ctx, spec.size, spec.spacing, 'anti'); + break; + case 'crosshatch': + drawDiagonalLines(ctx, spec.size, spec.spacing, 'anti'); + drawDiagonalLines(ctx, spec.size, spec.spacing, 'main'); + break; + case 'grid': + case 'horizontal': + case 'vertical': { + // Pixel-centred lines (0.5 offset) so 1px strokes stay crisp, matching + // the legacy SVG tiles (lines at 0.5 / 3.5 / 6.5, etc.). + for (let p = 0.5; p < spec.size; p += spec.spacing) { + if (style !== 'horizontal') { + ctx.beginPath(); + ctx.moveTo(p, 0); + ctx.lineTo(p, spec.size); + ctx.stroke(); + } + if (style !== 'vertical') { + ctx.beginPath(); + ctx.moveTo(0, p); + ctx.lineTo(spec.size, p); + ctx.stroke(); + } + } + break; + } + } + return tile; +}; + +/** + * Produces a colorized RGBA surface the size of the mask cache: the mask's alpha + * is the stencil, the `fill` supplies the colour (solid) or a repeat `tile` + * (pattern), composited `source-in`. The caller blits the result through the + * layer transform. + */ +export const colorizeMask = ( + backend: RasterBackend, + mask: RasterSurface, + width: number, + height: number, + fill: CanvasMaskFillContract, + tile: RasterSurface | null, + target: RasterSurface | null = null +): RasterSurface => { + const out = target ?? backend.createSurface(width, height); + if (out.width !== width || out.height !== height) { + out.resize(width, height); + } + const ctx = out.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, width, height); + // 1. The mask alpha stencil. + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'source-over'; + ctx.drawImage(mask.canvas, 0, 0); + // 2. Colour/pattern kept only where the stencil is opaque (`source-in`). + ctx.globalCompositeOperation = 'source-in'; + if (tile) { + const pattern = ctx.createPattern(tile.canvas, 'repeat'); + ctx.fillStyle = pattern ?? fill.color; + } else { + ctx.fillStyle = fill.color; + } + ctx.fillRect(0, 0, width, height); + return out; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/overlayRenderer.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/overlayRenderer.test.ts new file mode 100644 index 00000000000..06a3f9eac00 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/overlayRenderer.test.ts @@ -0,0 +1,186 @@ +import type { Mat2d, Rect } from '@workbench/canvas-engine/types'; + +import { identity, scale } from '@workbench/canvas-engine/math/mat2d'; +import { describe, expect, it } from 'vitest'; + +import type { OverlayState } from './overlayRenderer'; +import type { RasterCallLogEntry } from './raster.testStub'; + +import { MIN_GRID_SPACING_PX, renderOverlay } from './overlayRenderer'; +import { createTestStubRasterBackend } from './raster.testStub'; + +const BBOX: Rect = { height: 40, width: 40, x: 10, y: 10 }; + +const findSet = (log: RasterCallLogEntry[], prop: string): unknown[] => + log.filter((e) => e.op === 'set' && e.args[0] === prop).map((e) => e.args[1]); + +const baseState = (overrides: Partial = {}): OverlayState => ({ + bbox: BBOX, + view: identity(), + ...overrides, +}); + +describe('renderOverlay', () => { + it('clears then strokes ONLY the bbox (dashed) — no document outline (unbounded plane)', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + + renderOverlay(target, baseState()); + + const ops = target.callLog.map((e) => e.op); + expect(ops).toContain('clearRect'); + // The document rect is retired as a visual boundary: the bbox is the only rect stroked. + expect(target.callLog.filter((e) => e.op === 'strokeRect')).toHaveLength(1); + // At least one dashed setLineDash for the bbox. + const dashArgs = target.callLog.filter((e) => e.op === 'setLineDash').map((e) => e.args[0]); + expect(dashArgs).toContainEqual([4, 4]); + }); + + it('draws no grid when showGrid is false', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay(target, baseState({ gridSize: 10, showGrid: false })); + // No path building beyond stroking rects (no moveTo/lineTo for grid lines). + expect(target.callLog.some((e) => e.op === 'moveTo')).toBe(false); + }); + + it('draws grid lines when enabled and spacing is large enough', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay(target, baseState({ gridSize: 20, showGrid: true, view: scale(identity(), 2) })); + expect(target.callLog.some((e) => e.op === 'moveTo')).toBe(true); + expect(target.callLog.some((e) => e.op === 'lineTo')).toBe(true); + expect(target.callLog.some((e) => e.op === 'stroke')).toBe(true); + }); + + it('skips the grid when zoomed out below the density threshold', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + // gridSize * scale below MIN_GRID_SPACING_PX -> too dense, skip. + const smallScale = (MIN_GRID_SPACING_PX - 1) / 10; + const view: Mat2d = scale(identity(), smallScale); + renderOverlay(target, baseState({ gridSize: 10, showGrid: true, view })); + expect(target.callLog.some((e) => e.op === 'moveTo')).toBe(false); + }); + + it('draws a brush cursor ring scaled from document units', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay( + target, + baseState({ cursor: { point: { x: 50, y: 50 }, radiusDoc: 10 }, view: scale(identity(), 2) }) + ); + + const arc = target.callLog.find((e) => e.op === 'arc'); + expect(arc).toBeDefined(); + // center = point * 2, radius = radiusDoc * scale(2) = 20. + expect(arc!.args.slice(0, 3)).toEqual([100, 100, 20]); + }); + + it('omits the cursor ring when cursor is null', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay(target, baseState({ cursor: null })); + expect(target.callLog.some((e) => e.op === 'arc')).toBe(false); + }); + + it('strokes the bbox in its own accent color (no document-outline color)', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay(target, baseState()); + const strokeStyles = findSet(target.callLog, 'strokeStyle'); + // With the document outline gone, the bbox is the only stroked chrome here: + // exactly one stroke color is applied. + expect(strokeStyles).toEqual(['#3b82f6']); + }); + + it('hides the passive bbox frame when showBbox is false', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay(target, baseState({ showBbox: false })); + expect(target.callLog.filter((e) => e.op === 'strokeRect')).toHaveLength(0); + }); + + it('draws the bbox-overlay shade (evenodd punch-out) only when bboxOverlay is on', () => { + const backend = createTestStubRasterBackend(); + const off = backend.createSurface(200, 200); + renderOverlay(off, baseState()); + expect(off.callLog.some((e) => e.op === 'fill' && e.args[0] === 'evenodd')).toBe(false); + + const on = backend.createSurface(200, 200); + renderOverlay(on, baseState({ bboxOverlay: true })); + // One even-odd fill: the viewport rect with the bbox rect punched out. + expect(on.callLog.filter((e) => e.op === 'fill' && e.args[0] === 'evenodd')).toHaveLength(1); + const rects = on.callLog.filter((e) => e.op === 'rect').map((e) => e.args); + expect(rects).toContainEqual([0, 0, 200, 200]); + expect(rects).toContainEqual([BBOX.x, BBOX.y, BBOX.width, BBOX.height]); + }); + + it('draws rule-of-thirds guides inside the bbox when enabled', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay(target, baseState({ ruleOfThirds: true })); + // Four guide lines: two vertical + two horizontal moveTo/lineTo pairs. + const moveTos = target.callLog.filter((e) => e.op === 'moveTo'); + expect(moveTos).toHaveLength(4); + }); + + it('draws the dedicated SAM mask preview before its bbox, handles, and colored points', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + const mask = backend.createSurface(20, 10); + renderOverlay( + target, + baseState({ + samInput: { + bbox: { height: 30, width: 40, x: 20, y: 30 }, + excludePoints: [{ x: 50, y: 60 }], + includePoints: [{ x: 30, y: 40 }], + }, + samPreview: { opacity: 0.45, rect: { height: 10, width: 20, x: 20, y: 30 }, surface: mask }, + showBbox: false, + }) + ); + + const ops = target.callLog.map((entry) => entry.op); + const previewIndex = ops.indexOf('drawImage'); + const bboxIndex = ops.indexOf('strokeRect'); + const firstPointIndex = ops.indexOf('arc'); + expect(previewIndex).toBeGreaterThan(-1); + expect(bboxIndex).toBeGreaterThan(previewIndex); + expect(firstPointIndex).toBeGreaterThan(bboxIndex); + expect(findSet(target.callLog, 'fillStyle')).toEqual(expect.arrayContaining(['#22c55e', '#ef4444'])); + }); + + it.each([1, 4])('keeps SAM point and handle drawing screen-constant at %sx zoom', (zoom) => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(500, 500); + renderOverlay( + target, + baseState({ + samInput: { + bbox: { height: 30, width: 40, x: 20, y: 30 }, + excludePoints: [], + includePoints: [{ x: 30, y: 40 }], + }, + showBbox: false, + view: scale(identity(), zoom), + }) + ); + + const point = target.callLog.find((entry) => entry.op === 'arc'); + const handles = target.callLog.filter((entry) => entry.op === 'fillRect'); + expect(point?.args[2]).toBe(5); + expect(handles).toHaveLength(8); + expect(handles.every((entry) => entry.args[2] === 8 && entry.args[3] === 8)).toBe(true); + }); + + it('draws no SAM preview or geometry after cleanup', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay(target, baseState({ samInput: null, samPreview: null, showBbox: false })); + + expect(target.callLog.some((entry) => entry.op === 'drawImage' || entry.op === 'arc')).toBe(false); + expect(target.callLog.filter((entry) => entry.op === 'strokeRect')).toHaveLength(0); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/overlayRenderer.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/overlayRenderer.ts new file mode 100644 index 00000000000..a83e8232cfd --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/overlayRenderer.ts @@ -0,0 +1,546 @@ +/** + * Renders the interaction overlay — the second stacked canvas above the + * composited document: bbox rectangle, an optional viewport-wide grid, and + * the brush cursor ring. Everything is drawn in screen space (document points + * projected through `view`) so strokes stay a constant pixel width regardless + * of zoom. The canvas is an unbounded plane, so no document-bounds outline is + * drawn. + * + * Marching ants and transform handles come later; the functions here are kept + * small and composable so those can slot in. Every pixel operation flows + * through the {@link RasterSurface} `ctx` seam. Zero React, zero import-time + * side effects. + */ + +import type { Mat2d, Rect, Vec2 } from '@workbench/canvas-engine/types'; + +import { applyToPoint, getScale, invert } from '@workbench/canvas-engine/math/mat2d'; +import { transformBounds } from '@workbench/canvas-engine/math/rect'; +import { drawMarchingAnts, type MarchingAntsRender } from '@workbench/canvas-engine/selection/marchingAnts'; +import { BBOX_HANDLES, bboxHandlePoint } from '@workbench/canvas-engine/tools/bboxHitTest'; +import { TRANSFORM_ROTATE_NUB_PX } from '@workbench/canvas-engine/transform/transformMath'; + +import type { RasterSurface } from './raster'; + +/** Minimum screen-space grid spacing (px) below which the grid is too dense to draw. */ +export const MIN_GRID_SPACING_PX = 8; + +const BBOX_COLOR = '#3b82f6'; +const BBOX_DASH: readonly number[] = [4, 4]; +/** The bbox-overlay dim fill (legacy `CanvasBboxToolModule` overlayRect parity). */ +const BBOX_OVERLAY_FILL = 'hsl(220 12% 10% / 0.8)'; +const GRID_COLOR = 'rgba(128, 128, 128, 0.25)'; +const CURSOR_COLOR = '#ffffff'; +/** Side length (screen px) of a drawn bbox resize handle. */ +const BBOX_HANDLE_DRAW_PX = 8; +const BBOX_HANDLE_FILL = '#ffffff'; +/** Solid accent used for the selected-layer bounds outline while the move tool is active. */ +const LAYER_OUTLINE_COLOR = '#38bdf8'; +/** Accent for the transform frame (outline, handles, rotation nub). */ +const TRANSFORM_COLOR = '#38bdf8'; +const TRANSFORM_HANDLE_FILL = '#ffffff'; +/** Side length (screen px) of a drawn transform scale handle. */ +const TRANSFORM_HANDLE_DRAW_PX = 8; +/** Screen-px radius of the rotation-indicator knob at the nub's tip. */ +const TRANSFORM_ROTATE_KNOB_PX = 3.5; + +/** The brush cursor ring: center in document space, radius in document units. */ +export interface OverlayCursor { + point: Vec2; + radiusDoc: number; +} + +/** Everything the overlay needs to draw a frame. */ +export interface OverlayState { + /** Document→screen transform. */ + view: Mat2d; + /** Generation bounding box in document space. */ + bbox: Rect; + /** Whether to draw the eight bbox resize handles (bbox tool active). */ + bboxHandles?: boolean; + /** Whether to draw the passive bbox frame (default when absent: drawn). */ + showBbox?: boolean; + /** Whether to dim everything outside the bbox (the legacy "bbox overlay" shade). */ + bboxOverlay?: boolean; + /** Whether to draw the rule-of-thirds guides inside the bbox. */ + ruleOfThirds?: boolean; + /** Whether to draw the grid. */ + showGrid?: boolean; + /** Grid spacing in document units. */ + gridSize?: number; + /** Brush cursor ring, or `null`/absent to hide it. */ + cursor?: OverlayCursor | null; + /** + * A layer's rendered-bounds outline (the move tool's selection marquee): the + * four document-space corners, projected through `view` and stroked as a closed + * polygon. Absent/`null` to hide it. + */ + layerOutline?: readonly Vec2[] | null; + /** + * The active transform-tool frame: the layer's rotated bounds, its eight scale + * handles, and the center/top-edge points for the rotation nub — all in + * document space, projected through `view`. Absent/`null` when no transform + * session is active. + */ + transformFrame?: TransformFrameOverlay | null; + /** + * The in-progress lasso polygon (document-space points), drawn as a live dashed + * outline while a lasso drag is underway. Absent/`null` when idle. + */ + lassoPreview?: readonly Vec2[] | null; + /** + * The committed selection's marching ants: the outline paths (document space) + * plus the animated dash phase. Absent/`null` when there is no selection. + */ + marchingAnts?: MarchingAntsRender | null; + /** + * The in-progress shape-tool drag (document-space rect + kind), drawn as a + * live outline while a shape is being created. Absent/`null` when idle. + */ + shapePreview?: { rect: Rect; kind: 'rect' | 'ellipse' } | null; + /** + * The in-progress gradient-tool drag vector (document-space start/end), + * drawn as a direction indicator. Absent/`null` when idle. + */ + gradientPreview?: { start: Vec2; end: Vec2 } | null; + /** Dedicated Select Object mask preview, already colorized by the engine. */ + samPreview?: { surface: RasterSurface; rect: Rect; opacity: number } | null; + /** Select Object visual prompt geometry in document space. */ + samInput?: { includePoints: readonly Vec2[]; excludePoints: readonly Vec2[]; bbox: Rect | null } | null; +} + +/** Document-space geometry the overlay draws for an active transform session. */ +export interface TransformFrameOverlay { + /** The four rotated-rect corners (closed polygon). */ + corners: readonly Vec2[]; + /** The eight scale-handle positions. */ + handles: readonly Vec2[]; + /** The layer center (rotation nub direction reference). */ + center: Vec2; + /** The top edge midpoint (root of the rotation nub). */ + rotationAnchor: Vec2; +} + +type Ctx = RasterSurface['ctx']; + +const strokeRectScreen = (ctx: Ctx, screenRect: Rect): void => { + ctx.strokeRect(screenRect.x, screenRect.y, screenRect.width, screenRect.height); +}; + +/** + * Draws grid lines across the entire viewport, in screen space, skipping when + * too dense. The canvas is an unbounded plane, so the grid is not clipped to any + * document rect: the visible screen rect is projected back into document space + * (via the inverse view) and snapped out to whole grid cells so the lines tile + * seamlessly while panning/zooming. + */ +const drawGrid = (ctx: Ctx, state: OverlayState, target: RasterSurface): void => { + const gridSize = state.gridSize ?? 0; + if (gridSize <= 0) { + return; + } + const scale = getScale(state.view); + if (gridSize * scale < MIN_GRID_SPACING_PX) { + return; + } + + const { view } = state; + const inv = invert(view); + if (!inv) { + return; + } + // Document-space bounds of the viewport's four screen corners. + const corners = [ + applyToPoint(inv, { x: 0, y: 0 }), + applyToPoint(inv, { x: target.width, y: 0 }), + applyToPoint(inv, { x: 0, y: target.height }), + applyToPoint(inv, { x: target.width, y: target.height }), + ]; + const xs = corners.map((p) => p.x); + const ys = corners.map((p) => p.y); + // Snap outward to whole grid cells so lines are stable across pan/zoom. + const left = Math.floor(Math.min(...xs) / gridSize) * gridSize; + const top = Math.floor(Math.min(...ys) / gridSize) * gridSize; + const right = Math.ceil(Math.max(...xs) / gridSize) * gridSize; + const bottom = Math.ceil(Math.max(...ys) / gridSize) * gridSize; + + ctx.save(); + ctx.strokeStyle = GRID_COLOR; + ctx.lineWidth = 1; + ctx.setLineDash([]); + ctx.beginPath(); + for (let x = left; x <= right; x += gridSize) { + const a = applyToPoint(view, { x, y: top }); + const b = applyToPoint(view, { x, y: bottom }); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + } + for (let y = top; y <= bottom; y += gridSize) { + const a = applyToPoint(view, { x: left, y }); + const b = applyToPoint(view, { x: right, y }); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + } + ctx.stroke(); + ctx.restore(); +}; + +/** Draws the brush cursor ring at the pointer, radius scaled from document units. */ +const drawCursor = (ctx: Ctx, state: OverlayState): void => { + const cursor = state.cursor; + if (!cursor) { + return; + } + const center = applyToPoint(state.view, cursor.point); + const radius = cursor.radiusDoc * getScale(state.view); + ctx.save(); + ctx.strokeStyle = CURSOR_COLOR; + ctx.lineWidth = 1; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.arc(center.x, center.y, Math.max(0, radius), 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); +}; + +/** Draws a layer's rendered-bounds outline as a closed polygon in screen space. */ +const drawLayerOutline = (ctx: Ctx, state: OverlayState): void => { + const corners = state.layerOutline; + if (!corners || corners.length < 2) { + return; + } + ctx.save(); + ctx.strokeStyle = LAYER_OUTLINE_COLOR; + ctx.lineWidth = 1; + ctx.setLineDash([]); + ctx.beginPath(); + corners.forEach((corner, index) => { + const p = applyToPoint(state.view, corner); + if (index === 0) { + ctx.moveTo(p.x, p.y); + } else { + ctx.lineTo(p.x, p.y); + } + }); + ctx.closePath(); + ctx.stroke(); + ctx.restore(); +}; + +/** + * Draws the transform frame (rotated bounds polygon, eight scale handles, and a + * rotation nub past the top edge) in screen space. Handles/nub stay a constant + * pixel size; the nub direction follows the rotated frame (top-edge → away from + * center), so it reads correctly at any layer rotation. + */ +const drawTransformFrame = (ctx: Ctx, state: OverlayState): void => { + const frame = state.transformFrame; + if (!frame || frame.corners.length < 3) { + return; + } + const { view } = state; + ctx.save(); + ctx.setLineDash([]); + ctx.strokeStyle = TRANSFORM_COLOR; + ctx.lineWidth = 1; + + // Bounds polygon. + ctx.beginPath(); + frame.corners.forEach((corner, index) => { + const p = applyToPoint(view, corner); + if (index === 0) { + ctx.moveTo(p.x, p.y); + } else { + ctx.lineTo(p.x, p.y); + } + }); + ctx.closePath(); + ctx.stroke(); + + // Rotation nub: from the top-edge midpoint, outward (away from center), a + // constant screen length; a small knob at the tip. + const anchor = applyToPoint(view, frame.rotationAnchor); + const center = applyToPoint(view, frame.center); + const dx = anchor.x - center.x; + const dy = anchor.y - center.y; + const len = Math.hypot(dx, dy) || 1; + const nx = anchor.x + (dx / len) * TRANSFORM_ROTATE_NUB_PX; + const ny = anchor.y + (dy / len) * TRANSFORM_ROTATE_NUB_PX; + ctx.beginPath(); + ctx.moveTo(anchor.x, anchor.y); + ctx.lineTo(nx, ny); + ctx.stroke(); + ctx.beginPath(); + ctx.fillStyle = TRANSFORM_HANDLE_FILL; + ctx.arc(nx, ny, TRANSFORM_ROTATE_KNOB_PX, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // Scale handles. + const half = TRANSFORM_HANDLE_DRAW_PX / 2; + ctx.fillStyle = TRANSFORM_HANDLE_FILL; + for (const handle of frame.handles) { + const p = applyToPoint(view, handle); + ctx.fillRect(p.x - half, p.y - half, TRANSFORM_HANDLE_DRAW_PX, TRANSFORM_HANDLE_DRAW_PX); + ctx.strokeRect(p.x - half, p.y - half, TRANSFORM_HANDLE_DRAW_PX, TRANSFORM_HANDLE_DRAW_PX); + } + ctx.restore(); +}; + +const LASSO_PREVIEW_COLOR = '#38bdf8'; +const LASSO_PREVIEW_DASH: readonly number[] = [4, 4]; + +/** Draws the in-progress lasso polygon as a dashed screen-space outline. */ +const drawLassoPreview = (ctx: Ctx, state: OverlayState): void => { + const points = state.lassoPreview; + if (!points || points.length < 2) { + return; + } + ctx.save(); + ctx.strokeStyle = LASSO_PREVIEW_COLOR; + ctx.lineWidth = 1; + ctx.setLineDash([...LASSO_PREVIEW_DASH]); + ctx.beginPath(); + points.forEach((point, index) => { + const p = applyToPoint(state.view, point); + if (index === 0) { + ctx.moveTo(p.x, p.y); + } else { + ctx.lineTo(p.x, p.y); + } + }); + // Close the loop back to the start so the enclosed region reads clearly. + ctx.closePath(); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); +}; + +/** Draws the shape-tool drag preview (rect or ellipse outline) in screen space. */ +const drawShapePreview = (ctx: Ctx, state: OverlayState): void => { + const preview = state.shapePreview; + if (!preview || preview.rect.width <= 0 || preview.rect.height <= 0) { + return; + } + const screen = transformBounds(state.view, preview.rect); + ctx.save(); + ctx.strokeStyle = LAYER_OUTLINE_COLOR; + ctx.lineWidth = 1; + ctx.setLineDash([...BBOX_DASH]); + ctx.beginPath(); + if (preview.kind === 'ellipse') { + ctx.ellipse( + screen.x + screen.width / 2, + screen.y + screen.height / 2, + Math.abs(screen.width) / 2, + Math.abs(screen.height) / 2, + 0, + 0, + Math.PI * 2 + ); + } else { + ctx.rect(screen.x, screen.y, screen.width, screen.height); + } + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); +}; + +/** Draws the gradient-tool drag vector (a line with endpoint dots) in screen space. */ +const drawGradientPreview = (ctx: Ctx, state: OverlayState): void => { + const preview = state.gradientPreview; + if (!preview) { + return; + } + const start = applyToPoint(state.view, preview.start); + const end = applyToPoint(state.view, preview.end); + ctx.save(); + ctx.strokeStyle = LAYER_OUTLINE_COLOR; + ctx.fillStyle = LAYER_OUTLINE_COLOR; + ctx.lineWidth = 1; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(start.x, start.y); + ctx.lineTo(end.x, end.y); + ctx.stroke(); + for (const point of [start, end]) { + ctx.beginPath(); + ctx.arc(point.x, point.y, 3, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); +}; + +const SAM_BBOX_COLOR = '#f59e0b'; +const SAM_INCLUDE_COLOR = '#22c55e'; +const SAM_EXCLUDE_COLOR = '#ef4444'; +const SAM_POINT_RADIUS_PX = 5; +const SAM_HANDLE_DRAW_PX = 8; + +const drawSamPreview = (ctx: Ctx, state: OverlayState): void => { + const preview = state.samPreview; + if (!preview) { + return; + } + const { view } = state; + ctx.save(); + ctx.setTransform(view.a, view.b, view.c, view.d, view.e, view.f); + ctx.globalAlpha = preview.opacity; + ctx.drawImage(preview.surface.canvas, preview.rect.x, preview.rect.y, preview.rect.width, preview.rect.height); + ctx.restore(); +}; + +const drawSamGeometry = (ctx: Ctx, state: OverlayState): void => { + const input = state.samInput; + if (!input) { + return; + } + ctx.save(); + ctx.lineWidth = 1; + ctx.strokeStyle = SAM_BBOX_COLOR; + ctx.setLineDash([...BBOX_DASH]); + if (input.bbox) { + const screenRect = transformBounds(state.view, input.bbox); + ctx.strokeRect(screenRect.x, screenRect.y, screenRect.width, screenRect.height); + ctx.setLineDash([]); + ctx.fillStyle = BBOX_HANDLE_FILL; + const half = SAM_HANDLE_DRAW_PX / 2; + for (const handle of BBOX_HANDLES) { + const center = bboxHandlePoint(screenRect, handle); + ctx.fillRect(center.x - half, center.y - half, SAM_HANDLE_DRAW_PX, SAM_HANDLE_DRAW_PX); + ctx.strokeRect(center.x - half, center.y - half, SAM_HANDLE_DRAW_PX, SAM_HANDLE_DRAW_PX); + } + } + ctx.setLineDash([]); + for (const [points, color] of [ + [input.includePoints, SAM_INCLUDE_COLOR], + [input.excludePoints, SAM_EXCLUDE_COLOR], + ] as const) { + ctx.fillStyle = color; + ctx.strokeStyle = '#ffffff'; + for (const point of points) { + const screen = applyToPoint(state.view, point); + ctx.beginPath(); + ctx.arc(screen.x, screen.y, SAM_POINT_RADIUS_PX, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + } + ctx.restore(); +}; + +/** + * Draws the bbox overlay shade: a translucent dark fill over the ENTIRE viewport + * with the bbox region punched out (even-odd fill rule — the two nested rect paths + * cancel inside the bbox), dimming everything outside the generation frame. Fill + * color matches legacy `CanvasBboxToolModule`'s overlayRect. + */ +const drawBboxOverlayShade = (ctx: Ctx, state: OverlayState, target: RasterSurface): void => { + if (!state.bboxOverlay) { + return; + } + const bboxScreen = transformBounds(state.view, state.bbox); + ctx.save(); + ctx.fillStyle = BBOX_OVERLAY_FILL; + ctx.beginPath(); + ctx.rect(0, 0, target.width, target.height); + ctx.rect(bboxScreen.x, bboxScreen.y, bboxScreen.width, bboxScreen.height); + ctx.fill('evenodd'); + ctx.restore(); +}; + +/** + * Draws the rule-of-thirds guides: two vertical and two horizontal lines dividing + * the bbox into thirds (screen space, solid, in the grid color). A composition aid + * over the generation frame. + */ +const drawRuleOfThirds = (ctx: Ctx, state: OverlayState): void => { + if (!state.ruleOfThirds) { + return; + } + const r = transformBounds(state.view, state.bbox); + ctx.save(); + ctx.setLineDash([]); + ctx.strokeStyle = GRID_COLOR; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let i = 1; i <= 2; i++) { + const x = r.x + (r.width * i) / 3; + const y = r.y + (r.height * i) / 3; + ctx.moveTo(x, r.y); + ctx.lineTo(x, r.y + r.height); + ctx.moveTo(r.x, y); + ctx.lineTo(r.x + r.width, y); + } + ctx.stroke(); + ctx.restore(); +}; + +/** Draws the eight bbox resize handles as small squares at the frame's corners/edges (screen space). */ +const drawBboxHandles = (ctx: Ctx, state: OverlayState): void => { + if (!state.bboxHandles) { + return; + } + const screenRect = transformBounds(state.view, state.bbox); + const half = BBOX_HANDLE_DRAW_PX / 2; + ctx.save(); + ctx.setLineDash([]); + ctx.fillStyle = BBOX_HANDLE_FILL; + ctx.strokeStyle = BBOX_COLOR; + ctx.lineWidth = 1; + for (const handle of BBOX_HANDLES) { + const center = bboxHandlePoint(screenRect, handle); + ctx.fillRect(center.x - half, center.y - half, BBOX_HANDLE_DRAW_PX, BBOX_HANDLE_DRAW_PX); + ctx.strokeRect(center.x - half, center.y - half, BBOX_HANDLE_DRAW_PX, BBOX_HANDLE_DRAW_PX); + } + ctx.restore(); +}; + +/** Clears and redraws the entire overlay for the given `state`. */ +export const renderOverlay = (target: RasterSurface, state: OverlayState): void => { + const ctx = target.ctx; + + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, target.width, target.height); + + // The document rect is retired as a visual boundary (unbounded plane), so no + // document outline is drawn. The bbox (generation frame) is the primary anchor. + + // The bbox overlay shade first: it dims the composited document outside the + // bbox, and every piece of overlay chrome (grid, guides, frame) draws over it. + drawBboxOverlayShade(ctx, state, target); + + // Grid next (behind the bbox), spanning the whole viewport. + if (state.showGrid) { + drawGrid(ctx, state, target); + } + + // Rule-of-thirds guides sit inside the bbox, behind its frame. + drawRuleOfThirds(ctx, state); + + // Bbox (dashed, distinct color). Drawn as passive chrome unless the setting hides + // it (`showBbox === false`); the handles below still render for the bbox tool so + // the frame stays editable even when hidden. + if (state.showBbox ?? true) { + ctx.strokeStyle = BBOX_COLOR; + ctx.setLineDash([...BBOX_DASH]); + strokeRectScreen(ctx, transformBounds(state.view, state.bbox)); + ctx.setLineDash([]); + } + + drawSamPreview(ctx, state); + drawSamGeometry(ctx, state); + drawLayerOutline(ctx, state); + drawBboxHandles(ctx, state); + drawTransformFrame(ctx, state); + if (state.marchingAnts) { + drawMarchingAnts(ctx, state.view, state.marchingAnts); + } + drawLassoPreview(ctx, state); + drawShapePreview(ctx, state); + drawGradientPreview(ctx, state); + drawCursor(ctx, state); + + ctx.restore(); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.testStub.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.testStub.test.ts new file mode 100644 index 00000000000..cbc1d62a1a1 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.testStub.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import { createTestStubRasterBackend } from './raster.testStub'; + +describe('createTestStubRasterBackend', () => { + it('creates a surface with the requested dimensions', () => { + const backend = createTestStubRasterBackend(); + const surface = backend.createSurface(64, 32); + expect(surface.width).toBe(64); + expect(surface.height).toBe(32); + }); + + it('resize updates the surface dimensions and logs the call', () => { + const backend = createTestStubRasterBackend(); + const surface = backend.createSurface(10, 10); + surface.resize(20, 40); + expect(surface.width).toBe(20); + expect(surface.height).toBe(40); + expect(surface.callLog).toContainEqual({ op: 'resize', args: [20, 40] }); + }); + + it('records draw calls made against the surface context', () => { + const backend = createTestStubRasterBackend(); + const surface = backend.createSurface(10, 10); + const ctx = surface.ctx; + + ctx.save(); + ctx.clearRect(0, 0, 10, 10); + ctx.setTransform(1, 0, 0, 1, 5, 5); + ctx.fill(); + ctx.restore(); + + const ops = surface.callLog.map((entry) => entry.op); + expect(ops).toEqual(['save', 'clearRect', 'setTransform', 'fill', 'restore']); + expect(surface.callLog[1]).toEqual({ op: 'clearRect', args: [0, 0, 10, 10] }); + expect(surface.callLog[2]).toEqual({ op: 'setTransform', args: [1, 0, 0, 1, 5, 5] }); + }); + + it('getImageData returns a correctly-sized, zeroed buffer', () => { + const backend = createTestStubRasterBackend(); + const surface = backend.createSurface(4, 3); + const imageData = surface.ctx.getImageData(0, 0, 4, 3); + expect(imageData.width).toBe(4); + expect(imageData.height).toBe(3); + expect(imageData.data.length).toBe(4 * 3 * 4); + expect(Array.from(imageData.data).every((v) => v === 0)).toBe(true); + }); + + it('putImageData is recorded in the call log', () => { + const backend = createTestStubRasterBackend(); + const surface = backend.createSurface(2, 2); + const imageData = surface.ctx.getImageData(0, 0, 2, 2); + surface.ctx.putImageData(imageData, 0, 0); + expect(surface.callLog.at(-1)).toEqual({ op: 'putImageData', args: [imageData, 0, 0] }); + }); + + it('createImageBitmap resolves to a bitmap-shaped object', async () => { + const backend = createTestStubRasterBackend(); + const fakeSource = {} as ImageBitmapSource; + const bitmap = await backend.createImageBitmap(fakeSource); + expect(bitmap).toHaveProperty('width'); + expect(bitmap).toHaveProperty('height'); + }); + + it('encodeSurface resolves to a deterministic, size-keyed image blob', async () => { + const backend = createTestStubRasterBackend(); + const surface = backend.createSurface(8, 4); + const blob = await backend.encodeSurface(surface); + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe('image/png'); + // Deterministic: same-size surfaces encode to identical bytes. + const again = await backend.encodeSurface(backend.createSurface(8, 4)); + expect(await blob.text()).toBe(await again.text()); + // Size participates in the encoding, so different sizes differ. + const other = await backend.encodeSurface(backend.createSurface(8, 5)); + expect(await other.text()).not.toBe(await blob.text()); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.testStub.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.testStub.ts new file mode 100644 index 00000000000..ed281a8671b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.testStub.ts @@ -0,0 +1,178 @@ +/** + * A node-safe `RasterBackend` stub for vitest. Surfaces are backed by a + * fake 2D context that records every call it receives instead of touching + * a real canvas, so engine tests can assert on what was drawn without a + * DOM or `OffscreenCanvas`. + * + * The subset of `CanvasRenderingContext2D` the engine currently uses is + * implemented as recorded methods (save/restore/clearRect/fillRect/ + * strokeRect/drawImage/path building/fill/stroke/clip/createPattern/ + * createLinearGradient/createRadialGradient/setTransform/ + * getImageData/putImageData/setLineDash). Gradients returned by the + * `create*Gradient` factories record their `addColorStop` calls on the same + * surface call log. Property assignments (fillStyle, + * globalAlpha, globalCompositeOperation, ...) are recorded too, via a + * `{ op: 'set', args: [prop, value] }` entry, so tests can assert that + * opacity/blend/style state was applied in order. Extend the method table in + * `createStubCtx` below as later tasks need more of the API surface. + */ + +import type { RasterBackend, RasterSurface } from './raster'; + +/** A single recorded call made against a stub context. */ +export interface RasterCallLogEntry { + op: string; + args: unknown[]; +} + +/** A `RasterSurface` created by the test stub backend, with its call log exposed. */ +export interface StubRasterSurface extends RasterSurface { + readonly callLog: RasterCallLogEntry[]; +} + +/** A `RasterBackend` whose `createSurface` returns the call-log-bearing `StubRasterSurface`. */ +export interface StubRasterBackend extends RasterBackend { + createSurface(width: number, height: number): StubRasterSurface; +} + +const createStubImageData = (width: number, height: number): ImageData => { + const data = new Uint8ClampedArray(Math.max(0, width) * Math.max(0, height) * 4); + return { colorSpace: 'srgb', data, height, width } as unknown as ImageData; +}; + +/** + * Deterministic per-character advance the stub's {@link measureText} multiplies + * by the current font's pixel size — the same `0.6` factor the text rasterizer's + * pure `estimateTextExtent` uses (`TEXT_CHAR_WIDTH_FACTOR`), so a text layer's + * measured surface size in node tests exactly matches its estimated extent. + */ +const STUB_CHAR_WIDTH_FACTOR = 0.6; + +/** Extracts the `px` size from a CSS `font` shorthand (e.g. `"700 48px Inter"` → 48), defaulting to 10. */ +const fontSizeFromShorthand = (font: unknown): number => { + const match = typeof font === 'string' ? /(\d+(?:\.\d+)?)px/.exec(font) : null; + return match ? parseFloat(match[1] ?? '10') : 10; +}; + +const createStubCtx = (callLog: RasterCallLogEntry[]): OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D => { + const log = (op: string, args: unknown[]): void => { + callLog.push({ args, op }); + }; + + // Stored non-method property values (fillStyle, globalAlpha, font, etc.). + // Declared before the method table so `measureText` can read the current + // `font` to produce font-size-dependent metrics. + const props: Record = {}; + + const methods: Record unknown> = { + arc: (...args: unknown[]) => log('arc', args), + beginPath: (...args: unknown[]) => log('beginPath', args), + clearRect: (...args: unknown[]) => log('clearRect', args), + clip: (...args: unknown[]) => log('clip', args), + closePath: (...args: unknown[]) => log('closePath', args), + fillText: (...args: unknown[]) => log('fillText', args), + // Deterministic, font-size-aware metrics: width = chars × fontSizePx × 0.6. + // No DOM/real canvas needed, so text measurement is reproducible in node. + measureText: (...args: unknown[]) => { + log('measureText', args); + const text = String(args[0] ?? ''); + const width = text.length * fontSizeFromShorthand(props.font) * STUB_CHAR_WIDTH_FACTOR; + return { width } as unknown as TextMetrics; + }, + createLinearGradient: (...args: unknown[]) => { + log('createLinearGradient', args); + // A recording CanvasGradient stand-in: every addColorStop is logged on + // the surface's own call log (as `addColorStop`), so tests can assert the + // stop offsets/colors that were applied to the gradient. + return { + addColorStop: (...stopArgs: unknown[]) => log('addColorStop', stopArgs), + } as unknown as CanvasGradient; + }, + createPattern: (...args: unknown[]) => { + log('createPattern', args); + // A non-null marker standing in for a CanvasPattern, so callers that + // guard on a null return (e.g. the checkerboard fill) proceed. + return { __stubPattern: true } as unknown as CanvasPattern; + }, + createRadialGradient: (...args: unknown[]) => { + log('createRadialGradient', args); + return { + addColorStop: (...stopArgs: unknown[]) => log('addColorStop', stopArgs), + } as unknown as CanvasGradient; + }, + drawImage: (...args: unknown[]) => log('drawImage', args), + ellipse: (...args: unknown[]) => log('ellipse', args), + fill: (...args: unknown[]) => log('fill', args), + fillRect: (...args: unknown[]) => log('fillRect', args), + getImageData: (...args: unknown[]) => { + const [sx, sy, sw, sh] = args as [number, number, number, number]; + log('getImageData', [sx, sy, sw, sh]); + return createStubImageData(sw, sh); + }, + lineTo: (...args: unknown[]) => log('lineTo', args), + moveTo: (...args: unknown[]) => log('moveTo', args), + putImageData: (...args: unknown[]) => log('putImageData', args), + rect: (...args: unknown[]) => log('rect', args), + restore: (...args: unknown[]) => log('restore', args), + save: (...args: unknown[]) => log('save', args), + setLineDash: (...args: unknown[]) => log('setLineDash', args), + setTransform: (...args: unknown[]) => log('setTransform', args), + stroke: (...args: unknown[]) => log('stroke', args), + strokeRect: (...args: unknown[]) => log('strokeRect', args), + }; + + // The proxy records every property assignment (into `props`, declared above) + // so tests can assert on applied state. + const proxy = new Proxy(methods, { + get(target, prop: string) { + if (prop in target) { + return target[prop]; + } + return props[prop]; + }, + set(_target, prop: string, value: unknown) { + props[prop] = value; + log('set', [prop, value]); + return true; + }, + }); + + return proxy as unknown as OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; +}; + +class StubRasterSurfaceImpl implements StubRasterSurface { + readonly callLog: RasterCallLogEntry[] = []; + readonly canvas: OffscreenCanvas | HTMLCanvasElement; + readonly ctx: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; + width: number; + height: number; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + this.canvas = { height, width } as unknown as OffscreenCanvas | HTMLCanvasElement; + this.ctx = createStubCtx(this.callLog); + } + + resize(w: number, h: number): void { + this.callLog.push({ args: [w, h], op: 'resize' }); + this.width = w; + this.height = h; + } +} + +/** + * Creates a `RasterBackend` whose surfaces are backed by a fake, node-safe + * 2D context that records draw calls instead of executing them. + */ +export const createTestStubRasterBackend = (): StubRasterBackend => ({ + createImageBitmap: (source: ImageBitmapSource): Promise => { + void source; + return Promise.resolve({ close: () => {}, height: 0, width: 0 } as unknown as ImageBitmap); + }, + createSurface: (width: number, height: number): StubRasterSurface => new StubRasterSurfaceImpl(width, height), + // Deterministic fake blob keyed on the surface size, so encode calls are + // reproducible in node without touching a real canvas. + encodeSurface: (surface: RasterSurface, type = 'image/png'): Promise => + Promise.resolve(new Blob([`stub-surface-${surface.width}x${surface.height}`], { type })), +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.ts new file mode 100644 index 00000000000..d9768165a2c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.ts @@ -0,0 +1,119 @@ +/** + * The RasterBackend seam: every place the engine needs a 2D canvas surface + * or an ImageBitmap, it goes through an injected `RasterBackend` instead of + * calling `document.createElement('canvas')` / `new OffscreenCanvas(...)` + * directly. This keeps the engine testable in node — tests inject + * `render/raster.testStub.ts`'s stub backend instead of `createDomRasterBackend()`. + */ + +/** A single drawable/resizable 2D surface, backed by either an OffscreenCanvas or an HTMLCanvasElement. */ +export interface RasterSurface { + readonly canvas: OffscreenCanvas | HTMLCanvasElement; + readonly ctx: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; + readonly width: number; + readonly height: number; + resize(w: number, h: number): void; +} + +/** Injectable factory for raster surfaces and image bitmaps. */ +export interface RasterBackend { + createSurface(width: number, height: number): RasterSurface; + createImageBitmap(source: ImageBitmapSource): Promise; + /** + * Encodes a surface's pixels to an image `Blob` (PNG by default). Used by the + * bitmap store to persist painted layers as content-hashed server images. + */ + encodeSurface(surface: RasterSurface, type?: string): Promise; +} + +const isOffscreenCanvasSupported = (): boolean => typeof OffscreenCanvas !== 'undefined'; + +/** Encodes a DOM/Offscreen surface's canvas to an image `Blob`. */ +const encodeDomSurface = (surface: RasterSurface, type: string): Promise => { + const { canvas } = surface; + if (typeof (canvas as OffscreenCanvas).convertToBlob === 'function') { + return (canvas as OffscreenCanvas).convertToBlob({ type }); + } + const htmlCanvas = canvas as HTMLCanvasElement; + return new Promise((resolve, reject) => { + htmlCanvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Failed to encode canvas surface to a Blob')); + } + }, type); + }); +}; + +class OffscreenRasterSurface implements RasterSurface { + canvas: OffscreenCanvas; + ctx: OffscreenCanvasRenderingContext2D; + width: number; + height: number; + + constructor(width: number, height: number) { + this.canvas = new OffscreenCanvas(width, height); + const ctx = this.canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to acquire a 2D context from OffscreenCanvas'); + } + this.ctx = ctx; + this.width = width; + this.height = height; + } + + resize(w: number, h: number): void { + this.canvas.width = w; + this.canvas.height = h; + this.width = w; + this.height = h; + } +} + +class DomCanvasRasterSurface implements RasterSurface { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + width: number; + height: number; + + constructor(width: number, height: number) { + this.canvas = document.createElement('canvas'); + this.canvas.width = width; + this.canvas.height = height; + const ctx = this.canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to acquire a 2D context from HTMLCanvasElement'); + } + this.ctx = ctx; + this.width = width; + this.height = height; + } + + resize(w: number, h: number): void { + this.canvas.width = w; + this.canvas.height = h; + this.width = w; + this.height = h; + } +} + +/** + * Creates a `RasterBackend` backed by the DOM/browser: `OffscreenCanvas` + * when available, falling back to `HTMLCanvasElement` otherwise (notably + * Safari < 16.4, which lacks `OffscreenCanvas` support). + */ +export const createDomRasterBackend = (): RasterBackend => { + const useOffscreen = isOffscreenCanvasSupported(); + return { + createSurface(width: number, height: number): RasterSurface { + return useOffscreen ? new OffscreenRasterSurface(width, height) : new DomCanvasRasterSurface(width, height); + }, + createImageBitmap(source: ImageBitmapSource): Promise { + return createImageBitmap(source); + }, + encodeSurface(surface: RasterSurface, type = 'image/png'): Promise { + return encodeDomSurface(surface, type); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterComposite.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterComposite.ts new file mode 100644 index 00000000000..ed392ee3530 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterComposite.ts @@ -0,0 +1,244 @@ +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Mat2d, Rect } from '@workbench/canvas-engine/types'; +import type { + CanvasAdjustmentsContract, + CanvasBlendMode, + CanvasDocumentContractV2, + CanvasLayerContract, + CanvasLayerSourceContract, + CanvasRasterLayerContractV2, +} from '@workbench/types'; + +import { fromTRS, multiply } from '@workbench/canvas-engine/math/mat2d'; +import { roundOut, transformBounds, union } from '@workbench/canvas-engine/math/rect'; +import { adjustmentsKey, applyAdjustments, isIdentityAdjustments } from '@workbench/canvas-engine/render/adjustments'; +import { blendToComposite } from '@workbench/canvas-engine/render/compositor'; + +type Ctx = RasterSurface['ctx']; + +/** The engine-owned structural subset needed to plan and render a raster layer contribution. */ +export interface CompositeLayerRef { + id: string; + sourceRef: string; + contentSize: { width: number; height: number }; + contentOffset: { x: number; y: number }; + transform: { x: number; y: number; scaleX: number; scaleY: number; rotation: number }; + opacity: number; + blendMode: CanvasBlendMode; + adjustments?: CanvasAdjustmentsContract; +} + +/** The engine-owned structural subset consumed by the raster compositor. */ +export interface CompositeEntry { + bbox: Rect; + layers: readonly CompositeLayerRef[]; +} + +export interface BaseRasterCompositeEntry extends CompositeEntry { + key: string; + kind: 'base-raster'; + layers: CompositeLayerRef[]; +} + +export interface RenderRasterCompositeDeps { + backend: { + createSurface(width: number, height: number): RasterSurface; + }; + getLayerSurface(layerId: string): Promise<{ surface: RasterSurface; rect: Rect }>; + readImageData?(surface: RasterSurface, rect: Rect): ImageData; + writeImageData?(surface: RasterSurface, imageData: ImageData, x: number, y: number): void; +} + +const defaultReadImageData = (surface: RasterSurface, rect: Rect): ImageData => + surface.ctx.getImageData(rect.x, rect.y, rect.width, rect.height); + +const defaultWriteImageData = (surface: RasterSurface, imageData: ImageData, x: number, y: number): void => + surface.ctx.putImageData(imageData, x, y); + +/** True when a layer is an enabled raster layer with rasterizable, non-empty pixels. */ +const isBaseRasterLayer = (layer: CanvasLayerContract): layer is CanvasRasterLayerContractV2 => { + if (!layer.isEnabled || layer.type !== 'raster') { + return false; + } + if (layer.source.type === 'image') { + return true; + } + return layer.source.type === 'paint' && layer.source.bitmap !== null; +}; + +/** A stable string identifying a source's pixels (its asset name, or an empty sentinel). */ +const sourceRefOf = (source: CanvasLayerSourceContract): string => { + switch (source.type) { + case 'image': + return `image:${source.image.imageName}`; + case 'paint': + return source.bitmap ? `paint:${source.bitmap.imageName}` : 'paint:empty'; + default: + return `${source.type}:unsupported`; + } +}; + +/** The native (unscaled) content rect of a base-raster layer's source (layer-local). */ +const contentRectOf = (layer: CanvasRasterLayerContractV2, doc: CanvasDocumentContractV2): Rect => { + const { source } = layer; + if (source.type === 'image') { + return { height: source.image.height, width: source.image.width, x: 0, y: 0 }; + } + if (source.type === 'paint' && source.bitmap) { + const offset = source.offset ?? { x: 0, y: 0 }; + return { height: source.bitmap.height, width: source.bitmap.width, x: offset.x, y: offset.y }; + } + return { height: doc.height, width: doc.width, x: 0, y: 0 }; +}; + +/** Projects a document layer into its frozen composite contribution. */ +const toLayerRef = (layer: CanvasRasterLayerContractV2, doc: CanvasDocumentContractV2): CompositeLayerRef => { + const rect = contentRectOf(layer, doc); + const hasAdjustments = !isIdentityAdjustments(layer.adjustments); + return { + blendMode: layer.blendMode, + contentOffset: { x: rect.x, y: rect.y }, + contentSize: { height: rect.height, width: rect.width }, + id: layer.id, + opacity: layer.opacity, + sourceRef: sourceRefOf(layer.source), + ...(hasAdjustments && layer.adjustments ? { adjustments: layer.adjustments } : {}), + transform: { + rotation: layer.transform.rotation, + scaleX: layer.transform.scaleX, + scaleY: layer.transform.scaleY, + x: layer.transform.x, + y: layer.transform.y, + }, + }; +}; + +const rectKey = (rect: Rect): string => `${rect.x},${rect.y},${rect.width},${rect.height}`; + +const layerKey = (ref: CompositeLayerRef): string => { + const t = ref.transform; + const o = ref.contentOffset; + return [ + ref.id, + ref.sourceRef, + o.x, + o.y, + t.x, + t.y, + t.scaleX, + t.scaleY, + t.rotation, + ref.opacity, + ref.blendMode, + ref.adjustments ? adjustmentsKey(ref.adjustments) : '-', + ].join(':'); +}; + +/** Union of the provided composite-layer bounds in document space, or `null` when empty. */ +export const getCompositeLayerBounds = (layers: readonly CompositeLayerRef[]): Rect | null => { + let bounds: Rect | null = null; + for (const ref of layers) { + const nativeRect: Rect = { + height: ref.contentSize.height, + width: ref.contentSize.width, + x: ref.contentOffset.x, + y: ref.contentOffset.y, + }; + const layerBounds = transformBounds(layerMatrix(ref), nativeRect); + bounds = bounds === null ? layerBounds : union(bounds, layerBounds); + } + return bounds; +}; + +/** Plans the enabled base-raster layers over an exact document-space rectangle. */ +export const planBaseRasterComposite = (document: CanvasDocumentContractV2, rect: Rect): BaseRasterCompositeEntry => { + const layers = document.layers.filter(isBaseRasterLayer).map((layer) => toLayerRef(layer, document)); + return { + bbox: rect, + key: `base-raster|${rectKey(rect)}|${layers.map(layerKey).join('|')}`, + kind: 'base-raster', + layers, + }; +}; + +/** Tight outward-rounded bounds of all enabled raster content in the document. */ +export const getBaseRasterContentBounds = (document: CanvasDocumentContractV2): Rect | null => { + const bounds = getCompositeLayerBounds(planBaseRasterComposite(document, document.bbox).layers); + return bounds === null ? null : roundOut(bounds); +}; + +/** Document→bbox translate matrix (the "view" the entry is composited under). */ +const bboxView = (bbox: Rect): Mat2d => ({ a: 1, b: 0, c: 0, d: 1, e: -bbox.x, f: -bbox.y }); + +/** Applies a matrix to a 2D context's transform. */ +const setTransform = (ctx: Ctx, m: Mat2d): void => { + ctx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f); +}; + +/** The layer's local→document transform matrix. */ +const layerMatrix = (ref: CompositeLayerRef): Mat2d => + fromTRS( + { x: ref.transform.x, y: ref.transform.y }, + ref.transform.rotation, + ref.transform.scaleX, + ref.transform.scaleY + ); + +/** Composites an entry's layers, in z-order, onto a fresh bbox-sized surface. */ +export const renderRasterComposite = async ( + entry: CompositeEntry, + deps: RenderRasterCompositeDeps +): Promise => { + const { bbox } = entry; + const width = Math.max(0, bbox.width); + const height = Math.max(0, bbox.height); + const surface = deps.backend.createSurface(width, height); + const ctx = surface.ctx; + const readImageData = deps.readImageData ?? defaultReadImageData; + const writeImageData = deps.writeImageData ?? defaultWriteImageData; + + setTransform(ctx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + ctx.clearRect(0, 0, surface.width, surface.height); + + const view = bboxView(bbox); + // Layers are stored top-first (index 0 = top-most); draw bottom→top. + for (let i = entry.layers.length - 1; i >= 0; i--) { + const ref = entry.layers[i]; + if (!ref) { + continue; + } + const layerSurface = await deps.getLayerSurface(ref.id); + if (ref.adjustments) { + // Bake non-destructive adjustments so the generated image matches what the + // user sees: render this layer alone into a bbox temp, apply the LUTs, then + // composite the adjusted temp with the layer's opacity/blend. + const temp = deps.backend.createSurface(width, height); + const tempCtx = temp.ctx; + setTransform(tempCtx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + tempCtx.clearRect(0, 0, width, height); + setTransform(tempCtx, multiply(view, layerMatrix(ref))); + tempCtx.drawImage(layerSurface.surface.canvas, layerSurface.rect.x, layerSurface.rect.y); + const fullRect: Rect = { height, width, x: 0, y: 0 }; + const pixels = readImageData(temp, fullRect); + applyAdjustments(pixels, ref.adjustments); + writeImageData(temp, pixels, 0, 0); + ctx.save(); + setTransform(ctx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + ctx.globalAlpha = ref.opacity; + ctx.globalCompositeOperation = blendToComposite(ref.blendMode); + ctx.drawImage(temp.canvas, 0, 0); + ctx.restore(); + continue; + } + ctx.save(); + ctx.globalAlpha = ref.opacity; + ctx.globalCompositeOperation = blendToComposite(ref.blendMode); + setTransform(ctx, multiply(view, layerMatrix(ref))); + // Draw the cache at its layer-local content origin (content-sized paint + // layers place their pixels off-zero). + ctx.drawImage(layerSurface.surface.canvas, layerSurface.rect.x, layerSurface.rect.y); + ctx.restore(); + } + + return surface; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/gradientRasterizer.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/gradientRasterizer.test.ts new file mode 100644 index 00000000000..ba15212d22a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/gradientRasterizer.test.ts @@ -0,0 +1,112 @@ +import type { StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it } from 'vitest'; + +import type { RasterizeDeps } from './types'; + +import { rasterizeGradientSource } from './gradientRasterizer'; + +type GradientSource = Extract; + +const makeDeps = (): RasterizeDeps => { + const backend = createTestStubRasterBackend(); + return { + backend, + documentSize: { height: 200, width: 300 }, + resolver: () => Promise.resolve(new Blob()), + store: createLayerCacheStore(backend), + }; +}; + +const ops = (surface: StubRasterSurface): string[] => surface.callLog.map((e) => e.op); + +const grad = (over: Partial = {}): GradientSource => ({ + angle: 0, + kind: 'linear', + stops: [ + { color: '#000000', offset: 0 }, + { color: '#ffffff', offset: 1 }, + ], + type: 'gradient', + ...over, +}); + +describe('rasterizeGradientSource — extent', () => { + it('defaults to the DOCUMENT extent for a legacy gradient (no explicit width/height)', async () => { + const deps = makeDeps(); + const { rect, surface } = await rasterizeGradientSource(grad(), deps); + expect(surface.width).toBe(300); + expect(surface.height).toBe(200); + expect(rect).toEqual({ height: 200, width: 300, x: 0, y: 0 }); + expect(ops(surface as StubRasterSurface)).toContain('fillRect'); + }); + + it('uses the explicit width/height extent when present (content-sized)', async () => { + const deps = makeDeps(); + const { rect, surface } = await rasterizeGradientSource(grad({ height: 90, width: 120 }), deps); + expect(surface.width).toBe(120); + expect(surface.height).toBe(90); + expect(rect).toEqual({ height: 90, width: 120, x: 0, y: 0 }); + }); +}); + +describe('rasterizeGradientSource — linear', () => { + it('creates a linear gradient and applies every stop', async () => { + const deps = makeDeps(); + const surface = (await rasterizeGradientSource(grad({ angle: 90 }), deps)).surface as StubRasterSurface; + expect(ops(surface)).toContain('createLinearGradient'); + const stops = surface.callLog.filter((e) => e.op === 'addColorStop'); + expect(stops).toHaveLength(2); + expect(stops[0]?.args).toEqual([0, '#000000']); + expect(stops[1]?.args).toEqual([1, '#ffffff']); + }); + + it('does not create a radial gradient for the linear kind', async () => { + const deps = makeDeps(); + const surface = (await rasterizeGradientSource(grad(), deps)).surface as StubRasterSurface; + expect(ops(surface)).not.toContain('createRadialGradient'); + }); +}); + +describe('rasterizeGradientSource — radial', () => { + it('creates a radial gradient covering the document and applies stops', async () => { + const deps = makeDeps(); + const surface = (await rasterizeGradientSource(grad({ kind: 'radial' }), deps)).surface as StubRasterSurface; + expect(ops(surface)).toContain('createRadialGradient'); + expect(ops(surface)).not.toContain('createLinearGradient'); + const stops = surface.callLog.filter((e) => e.op === 'addColorStop'); + expect(stops).toHaveLength(2); + }); + + it('supports more than two stops', async () => { + const deps = makeDeps(); + const surface = ( + await rasterizeGradientSource( + grad({ + stops: [ + { color: '#000000', offset: 0 }, + { color: '#ff0000', offset: 0.5 }, + { color: '#ffffff', offset: 1 }, + ], + }), + deps + ) + ).surface as StubRasterSurface; + const stops = surface.callLog.filter((e) => e.op === 'addColorStop'); + expect(stops).toHaveLength(3); + }); +}); + +describe('rasterizeGradientSource — target reuse', () => { + it('resizes and reuses a provided target surface to the document size', async () => { + const deps = makeDeps(); + const target = deps.backend.createSurface(10, 10); + const { surface } = await rasterizeGradientSource(grad(), deps, target); + expect(surface).toBe(target); + expect(surface.width).toBe(300); + expect(surface.height).toBe(200); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/gradientRasterizer.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/gradientRasterizer.ts new file mode 100644 index 00000000000..fc314890140 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/gradientRasterizer.ts @@ -0,0 +1,78 @@ +/** + * Rasterizes a `gradient` layer source (linear / radial). Gradient layers are + * PARAMETRIC and CONTENT-SIZED: the source carries an explicit `width`/`height` + * extent (set bbox-sized at creation, preserved across angle edits) and the + * gradient spans that extent. Legacy gradients that predate the extent field + * default to the document dims (see {@link RasterizeDeps.documentSize}), so they + * render identically. The compositor positions the extent via the layer transform. + * + * Linear: the gradient line runs through the extent center along `angle` + * (degrees; 0° = left→right), long enough to cover the box corners. Radial: + * centered on the extent, radius reaching the farthest corner. + * + * Zero React, zero import-time side effects. + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import type { RasterizeDeps, RasterizeResult } from './types'; + +type GradientSource = Extract; +type Ctx = RasterSurface['ctx']; + +/** Builds the CSS-like gradient for the source across a `width`×`height` box. */ +const buildGradient = (ctx: Ctx, source: GradientSource, width: number, height: number): CanvasGradient => { + const cx = width / 2; + const cy = height / 2; + + let gradient: CanvasGradient; + if (source.kind === 'radial') { + const radius = Math.hypot(width / 2, height / 2); + gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius); + } else { + const rad = (source.angle * Math.PI) / 180; + const dx = Math.cos(rad); + const dy = Math.sin(rad); + // Half-length along the direction that just covers the box (projection of + // the box's half-extents onto the gradient direction). + const half = (Math.abs(dx) * width + Math.abs(dy) * height) / 2; + gradient = ctx.createLinearGradient(cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half); + } + + for (const stop of source.stops) { + // Clamp offsets defensively: `addColorStop` throws for out-of-range values. + gradient.addColorStop(Math.min(1, Math.max(0, stop.offset)), stop.color); + } + return gradient; +}; + +/** + * Draws a gradient source into a surface sized to its explicit extent (or the + * document dims for legacy gradients), reusing `target` if provided. Synchronous + * work wrapped in a resolved promise so it shares the `rasterizeSource` dispatch + * signature. + */ +export const rasterizeGradientSource = ( + source: GradientSource, + deps: RasterizeDeps, + target?: RasterSurface +): Promise => { + const width = Math.max(1, Math.round(source.width ?? deps.documentSize.width)); + const height = Math.max(1, Math.round(source.height ?? deps.documentSize.height)); + + const surface = target ?? deps.backend.createSurface(width, height); + if (surface.width !== width || surface.height !== height) { + surface.resize(width, height); + } + const { ctx } = surface; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, width, height); + + if (source.stops.length > 0) { + ctx.fillStyle = buildGradient(ctx, source, width, height); + ctx.fillRect(0, 0, width, height); + } + + return Promise.resolve({ rect: { height, width, x: 0, y: 0 }, surface }); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/imageRasterizer.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/imageRasterizer.ts new file mode 100644 index 00000000000..270e04d2b99 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/imageRasterizer.ts @@ -0,0 +1,75 @@ +/** + * Rasterizes an `image` layer source: decodes the referenced asset to an + * `ImageBitmap` (cached per image name in the store) and blits it onto a + * surface sized to the bitmap. + * + * Decoding is async and goes entirely through injected seams (the resolver + * for bytes, the backend for `createImageBitmap`), so it runs in node tests + * with fakes. Zero React, zero import-time side effects. + */ + +import type { DecodedBitmapLease } from '@workbench/canvas-engine/render/decodedBitmapPool'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasImageRef } from '@workbench/types'; + +import { createDecodedBitmapPool } from '@workbench/canvas-engine/render/decodedBitmapPool'; + +import type { RasterizeDeps, RasterizeResult } from './types'; + +/** Acquires a short-lived decoded bitmap lease, coalescing concurrent decodes by image name. */ +export const resolveBitmap = async (image: CanvasImageRef, deps: RasterizeDeps): Promise => { + deps.signal?.throwIfAborted(); + const pool = deps.bitmapPool ?? createDecodedBitmapPool(); + const lease = await pool.acquire( + image.imageName, + async (decodeSignal) => { + const blob = await deps.resolver(image.imageName, decodeSignal); + decodeSignal?.throwIfAborted(); + const bitmap = await deps.backend.createImageBitmap(blob); + if (decodeSignal?.aborted) { + bitmap.close(); + decodeSignal.throwIfAborted(); + } + return bitmap; + }, + deps.signal + ); + if (deps.signal?.aborted) { + lease.release(); + deps.signal.throwIfAborted(); + } + return lease; +}; + +/** Draws a bitmap onto a surface sized to `width`x`height`, reusing `target` if given. */ +export const blitBitmap = ( + bitmap: ImageBitmap, + width: number, + height: number, + deps: RasterizeDeps, + target?: RasterSurface +): RasterSurface => { + const surface = target ?? deps.backend.createSurface(width, height); + if (surface.width !== width || surface.height !== height) { + surface.resize(width, height); + } + surface.ctx.clearRect(0, 0, width, height); + surface.ctx.drawImage(bitmap, 0, 0); + return surface; +}; + +/** Rasterizes an `image` source into a surface sized to the image ref. */ +export const rasterizeImageSource = async ( + source: { type: 'image'; image: CanvasImageRef }, + deps: RasterizeDeps, + target?: RasterSurface +): Promise => { + const lease = await resolveBitmap(source.image, deps); + try { + const { height, width } = source.image; + const surface = blitBitmap(lease.bitmap, width, height, deps, target); + return { rect: { height, width, x: 0, y: 0 }, surface }; + } finally { + lease.release(); + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/index.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/index.ts new file mode 100644 index 00000000000..76c0c33676e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/index.ts @@ -0,0 +1,54 @@ +/** + * Source rasterizer dispatch. + * + * Given a layer `source`, produces a {@link RasterSurface} holding its + * rasterized pixels. `image`, `paint`, `shape`, `gradient`, and `text` are + * implemented. A `polygon` shape (no points-editing UX yet) throws — the + * dispatch only routes rect/ellipse. + * + * Zero React, zero import-time side effects. + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import type { RasterizeDeps, RasterizeResult } from './types'; + +import { rasterizeGradientSource } from './gradientRasterizer'; +import { rasterizeImageSource } from './imageRasterizer'; +import { rasterizePaintSource } from './paintRasterizer'; +import { rasterizeShapeSource } from './shapeRasterizer'; +import { rasterizeTextSource } from './textRasterizer'; + +export type { ImageResolver, RasterizeDeps, RasterizeResult } from './types'; +export { rasterizeGradientSource } from './gradientRasterizer'; +export { rasterizeImageSource } from './imageRasterizer'; +export { rasterizePaintSource } from './paintRasterizer'; +export { rasterizeShapeSource } from './shapeRasterizer'; +export { rasterizeTextSource } from './textRasterizer'; + +/** + * Rasterizes any supported layer source into a surface. Throws only for the + * deferred `polygon` shape kind (no rasterizer yet). + */ +export const rasterizeSource = ( + source: CanvasLayerSourceContract, + deps: RasterizeDeps, + target?: RasterSurface +): Promise => { + switch (source.type) { + case 'image': + return rasterizeImageSource(source, deps, target); + case 'paint': + return rasterizePaintSource(source, deps, target); + case 'shape': + if (source.kind === 'polygon') { + throw new Error("rasterizeSource: 'polygon' shapes are not implemented yet (deferred)"); + } + return rasterizeShapeSource(source, deps, target); + case 'gradient': + return rasterizeGradientSource(source, deps, target); + case 'text': + return rasterizeTextSource(source, deps, target); + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/paintRasterizer.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/paintRasterizer.ts new file mode 100644 index 00000000000..996f0282bb6 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/paintRasterizer.ts @@ -0,0 +1,47 @@ +/** + * Rasterizes a `paint` layer source. Paint layers are CONTENT-SIZED: the + * persisted bitmap covers only the painted region, positioned in the layer's + * local space by the source `offset` (default `{ x: 0, y: 0 }` for legacy + * document-sized bitmaps). When the source has a bitmap it is decoded and + * blitted onto a surface sized to the bitmap; when it is `null` the layer is + * empty and the result is a zero-rect surface (a brand-new / cleared layer). + * + * Zero React, zero import-time side effects. + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasImageRef } from '@workbench/types'; + +import type { RasterizeDeps, RasterizeResult } from './types'; + +import { blitBitmap, resolveBitmap } from './imageRasterizer'; + +/** A `paint` source (its optional layer-local bitmap offset). */ +type PaintSource = { type: 'paint'; bitmap: CanvasImageRef | null; offset?: { x: number; y: number } }; + +/** Rasterizes a `paint` source into a content-sized surface placed at its offset. */ +export const rasterizePaintSource = async ( + source: PaintSource, + deps: RasterizeDeps, + target?: RasterSurface +): Promise => { + if (source.bitmap) { + const { height, width } = source.bitmap; + const offset = source.offset ?? { x: 0, y: 0 }; + const lease = await resolveBitmap(source.bitmap, deps); + try { + const surface = blitBitmap(lease.bitmap, width, height, deps, target); + return { rect: { height, width, x: offset.x, y: offset.y }, surface }; + } finally { + lease.release(); + } + } + + // No bitmap yet: an empty (zero-rect) layer. Collapse any reused target to 0×0 + // so the empty-layer invariants (skip in composite/hit-test/flush) hold. + const surface = target ?? deps.backend.createSurface(0, 0); + if (surface.width !== 0 || surface.height !== 0) { + surface.resize(0, 0); + } + return { rect: { height: 0, width: 0, x: 0, y: 0 }, surface }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/rasterizeSource.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/rasterizeSource.test.ts new file mode 100644 index 00000000000..4b5bc34e98e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/rasterizeSource.test.ts @@ -0,0 +1,232 @@ +import type { RasterBackend } from '@workbench/canvas-engine/render/raster'; +import type { CanvasImageRef, CanvasLayerSourceContract } from '@workbench/types'; + +import { createDecodedBitmapPool } from '@workbench/canvas-engine/render/decodedBitmapPool'; +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ImageResolver, RasterizeDeps } from './types'; + +import { rasterizeSource } from './index'; + +const imageRef = (imageName: string, width = 32, height = 16): CanvasImageRef => ({ height, imageName, width }); + +/** A backend whose `createImageBitmap` is a spy returning a closable fake bitmap. */ +const createSpyBackend = () => { + const stub = createTestStubRasterBackend(); + const createImageBitmap = vi.fn((source: ImageBitmapSource): Promise => { + void source; + return Promise.resolve({ close: vi.fn(), height: 16, width: 32 } as unknown as ImageBitmap); + }); + const backend: RasterBackend = { + createImageBitmap, + createSurface: stub.createSurface, + encodeSurface: stub.encodeSurface, + }; + return { backend, createImageBitmap, createSurface: stub.createSurface }; +}; + +const makeDeps = (resolver: ImageResolver, backend: RasterBackend): RasterizeDeps => ({ + backend, + bitmapPool: createDecodedBitmapPool(), + documentSize: { height: 200, width: 300 }, + resolver, + store: createLayerCacheStore(backend), +}); + +describe('rasterizeSource — image', () => { + it('gives the shared image resolver a cancellable pool-owned signal', async () => { + const { backend } = createSpyBackend(); + const controller = new AbortController(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = { ...makeDeps(resolver, backend), signal: controller.signal }; + + await rasterizeSource({ image: imageRef('signaled'), type: 'image' }, deps); + + const resolverSignal = resolver.mock.calls[0]?.[1]; + expect(resolverSignal).toBeInstanceOf(AbortSignal); + expect(resolverSignal).not.toBe(controller.signal); + }); + + it('closes a decoded bitmap instead of caching it when cancellation lands during decode', async () => { + const { backend } = createSpyBackend(); + let resolveDecoded!: (bitmap: ImageBitmap) => void; + const decoded = new Promise((resolve) => { + resolveDecoded = resolve; + }); + const bitmap = { close: vi.fn(), height: 16, width: 32 } as unknown as ImageBitmap; + backend.createImageBitmap = vi.fn(() => decoded); + const controller = new AbortController(); + const deps = { ...makeDeps(() => Promise.resolve(new Blob()), backend), signal: controller.signal }; + + const rasterized = rasterizeSource({ image: imageRef('cancelled-decode'), type: 'image' }, deps); + await Promise.resolve(); + controller.abort(); + resolveDecoded(bitmap); + + await expect(rasterized).rejects.toBe(controller.signal.reason); + expect(bitmap.close).toHaveBeenCalledTimes(1); + expect(deps.bitmapPool?.byteSize()).toBe(0); + }); + + it('decodes via the resolver + backend and coalesces concurrent callers', async () => { + const { backend, createImageBitmap } = createSpyBackend(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = makeDeps(resolver, backend); + const source: CanvasLayerSourceContract = { image: imageRef('cat'), type: 'image' }; + + const [resultA, resultB] = await Promise.all([rasterizeSource(source, deps), rasterizeSource(source, deps)]); + + // Both active rasterizations share one decode; the final release closes it. + expect(resolver).toHaveBeenCalledTimes(1); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + expect(deps.bitmapPool?.byteSize()).toBe(0); + + // Surface sized to the image ref; content rect at the origin. + expect(resultA.surface.width).toBe(32); + expect(resultA.surface.height).toBe(16); + expect(resultA.rect).toEqual({ height: 16, width: 32, x: 0, y: 0 }); + expect(resultB.surface.width).toBe(32); + }); + + it('keeps a shared decode alive when the first of two consumers aborts', async () => { + const { backend, createImageBitmap } = createSpyBackend(); + let resolveBlob!: (blob: Blob) => void; + const resolver = vi.fn( + (_imageName, signal) => + new Promise((resolve, reject) => { + resolveBlob = resolve; + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + }) + ); + const shared = makeDeps(resolver, backend); + const firstController = new AbortController(); + const secondController = new AbortController(); + const source: CanvasLayerSourceContract = { image: imageRef('shared'), type: 'image' }; + + const first = rasterizeSource(source, { ...shared, signal: firstController.signal }); + const second = rasterizeSource(source, { ...shared, signal: secondController.signal }); + await Promise.resolve(); + firstController.abort(); + resolveBlob(new Blob()); + + await expect(first).rejects.toBe(firstController.signal.reason); + await expect(second).resolves.toMatchObject({ rect: { height: 16, width: 32, x: 0, y: 0 } }); + expect(resolver).toHaveBeenCalledTimes(1); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + expect(shared.bitmapPool?.byteSize()).toBe(0); + }); + + it('draws the decoded bitmap onto the surface', async () => { + const { backend } = createSpyBackend(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = makeDeps(resolver, backend); + + const { surface } = await rasterizeSource({ image: imageRef('dog'), type: 'image' }, deps); + const ops = (surface as ReturnType['createSurface']>).callLog.map( + (e) => e.op + ); + expect(ops).toContain('clearRect'); + expect(ops).toContain('drawImage'); + }); +}); + +describe('rasterizeSource — paint', () => { + it('null bitmap produces an EMPTY (zero-rect) surface (no drawImage)', async () => { + const { backend, createImageBitmap } = createSpyBackend(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = makeDeps(resolver, backend); + + const { rect, surface } = await rasterizeSource({ bitmap: null, type: 'paint' }, deps); + + // Content-sized: an empty paint layer holds no pixels — a zero rect. + expect(rect).toEqual({ height: 0, width: 0, x: 0, y: 0 }); + expect(surface.width).toBe(0); + expect(surface.height).toBe(0); + expect(resolver).not.toHaveBeenCalled(); + expect(createImageBitmap).not.toHaveBeenCalled(); + const ops = (surface as ReturnType['createSurface']>).callLog.map( + (e) => e.op + ); + expect(ops).not.toContain('drawImage'); + }); + + it('a paint bitmap is decoded and blitted onto a content-sized surface at its offset', async () => { + const { backend, createImageBitmap } = createSpyBackend(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = makeDeps(resolver, backend); + + const { rect, surface } = await rasterizeSource( + { bitmap: imageRef('brush'), offset: { x: 40, y: 25 }, type: 'paint' }, + deps + ); + + // Sized to the bitmap dims, placed at the persisted offset. + expect(surface.width).toBe(32); + expect(surface.height).toBe(16); + expect(rect).toEqual({ height: 16, width: 32, x: 40, y: 25 }); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + }); + + it('a paint bitmap without an offset defaults to origin (0,0) — legacy back-compat', async () => { + const { backend } = createSpyBackend(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = makeDeps(resolver, backend); + + const { rect } = await rasterizeSource({ bitmap: imageRef('brush'), type: 'paint' }, deps); + expect(rect).toEqual({ height: 16, width: 32, x: 0, y: 0 }); + }); +}); + +describe('rasterizeSource — unimplemented sources', () => { + it('throws for the deferred polygon shape kind', () => { + const { backend } = createSpyBackend(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = makeDeps(resolver, backend); + const source = { + fill: '#000000', + height: 10, + kind: 'polygon', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 10, + } as unknown as CanvasLayerSourceContract; + expect(() => rasterizeSource(source, deps)).toThrow(/not implemented/i); + }); + + it('rasterizes shape, gradient, and text sources (no longer throwing)', async () => { + const { backend } = createSpyBackend(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = makeDeps(resolver, backend); + const shape = { + fill: '#000000', + height: 10, + kind: 'rect', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 10, + } as CanvasLayerSourceContract; + const gradient = { + angle: 0, + kind: 'linear', + stops: [{ color: '#000000', offset: 0 }], + type: 'gradient', + } as CanvasLayerSourceContract; + const text = { + align: 'left', + color: '#000000', + content: 'hi', + fontFamily: 'Inter', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + } as CanvasLayerSourceContract; + await expect(rasterizeSource(shape, deps)).resolves.toBeDefined(); + await expect(rasterizeSource(gradient, deps)).resolves.toBeDefined(); + await expect(rasterizeSource(text, deps)).resolves.toBeDefined(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/shapeRasterizer.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/shapeRasterizer.test.ts new file mode 100644 index 00000000000..479963d8771 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/shapeRasterizer.test.ts @@ -0,0 +1,111 @@ +import type { StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it } from 'vitest'; + +import type { RasterizeDeps } from './types'; + +import { rasterizeShapeSource } from './shapeRasterizer'; + +type ShapeSource = Extract; + +const makeDeps = (): RasterizeDeps => { + const backend = createTestStubRasterBackend(); + return { + backend, + documentSize: { height: 200, width: 300 }, + resolver: () => Promise.resolve(new Blob()), + store: createLayerCacheStore(backend), + }; +}; + +const ops = (surface: StubRasterSurface): string[] => surface.callLog.map((e) => e.op); + +const rect = (over: Partial = {}): ShapeSource => ({ + fill: '#ff0000', + height: 40, + kind: 'rect', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 60, + ...over, +}); + +describe('rasterizeShapeSource — extent + sizing', () => { + it('sizes the surface to the source width/height (not the document)', async () => { + const deps = makeDeps(); + const surface = (await rasterizeShapeSource(rect(), deps)).surface as StubRasterSurface; + expect(surface.width).toBe(60); + expect(surface.height).toBe(40); + }); + + it('clears before drawing so re-rasterizing into a reused target is clean', async () => { + const deps = makeDeps(); + const surface = (await rasterizeShapeSource(rect(), deps)).surface as StubRasterSurface; + expect(ops(surface)).toContain('clearRect'); + }); +}); + +describe('rasterizeShapeSource — rect', () => { + it('fills a rect covering the full extent when fill is set and no stroke', async () => { + const deps = makeDeps(); + const surface = (await rasterizeShapeSource(rect({ fill: '#00ff00', stroke: null }), deps)) + .surface as StubRasterSurface; + const log = surface.callLog; + // Fill style applied and a rect path filled. + expect(log.some((e) => e.op === 'set' && e.args[0] === 'fillStyle' && e.args[1] === '#00ff00')).toBe(true); + expect(ops(surface)).toContain('fill'); + // No stroke was drawn. + expect(ops(surface)).not.toContain('stroke'); + }); + + it('strokes with the stroke color + width, inset so it stays within the extent', async () => { + const deps = makeDeps(); + const surface = (await rasterizeShapeSource(rect({ fill: null, stroke: '#0000ff', strokeWidth: 8 }), deps)) + .surface as StubRasterSurface; + const log = surface.callLog; + expect(log.some((e) => e.op === 'set' && e.args[0] === 'strokeStyle' && e.args[1] === '#0000ff')).toBe(true); + expect(log.some((e) => e.op === 'set' && e.args[0] === 'lineWidth' && e.args[1] === 8)).toBe(true); + expect(ops(surface)).toContain('stroke'); + expect(ops(surface)).not.toContain('fill'); + }); + + it('draws both fill and stroke when both are set', async () => { + const deps = makeDeps(); + const surface = (await rasterizeShapeSource(rect({ fill: '#111111', stroke: '#222222', strokeWidth: 4 }), deps)) + .surface as StubRasterSurface; + expect(ops(surface)).toContain('fill'); + expect(ops(surface)).toContain('stroke'); + }); + + it('draws nothing when both fill and stroke are null', async () => { + const deps = makeDeps(); + const surface = (await rasterizeShapeSource(rect({ fill: null, stroke: null }), deps)).surface as StubRasterSurface; + expect(ops(surface)).not.toContain('fill'); + expect(ops(surface)).not.toContain('stroke'); + }); +}); + +describe('rasterizeShapeSource — ellipse', () => { + it('draws an ellipse path (not a rect) for the ellipse kind', async () => { + const deps = makeDeps(); + const surface = (await rasterizeShapeSource(rect({ fill: '#abcdef', kind: 'ellipse' }), deps)) + .surface as StubRasterSurface; + expect(ops(surface)).toContain('ellipse'); + expect(ops(surface)).toContain('fill'); + }); +}); + +describe('rasterizeShapeSource — target reuse', () => { + it('resizes and reuses a provided target surface', async () => { + const deps = makeDeps(); + const target = deps.backend.createSurface(10, 10); + const { surface } = await rasterizeShapeSource(rect({ height: 40, width: 60 }), deps, target); + expect(surface).toBe(target); + expect(surface.width).toBe(60); + expect(surface.height).toBe(40); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/shapeRasterizer.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/shapeRasterizer.ts new file mode 100644 index 00000000000..c4150d75fc0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/shapeRasterizer.ts @@ -0,0 +1,79 @@ +/** + * Rasterizes a `shape` layer source (rect / ellipse). Shape layers are + * PARAMETRIC: their pixels are derived from the source params (`width`, + * `height`, `fill`, `stroke`, `strokeWidth`, `kind`) rather than a persisted + * bitmap, so they re-render for free whenever a param changes. + * + * Extent semantics: a shape's surface is sized to the source's own + * `width`×`height` (its layer-local extent), NOT the document — the compositor + * applies the layer transform (position/scale/rotation) when drawing, exactly + * like an `image` layer. The stroke is drawn INSET by `strokeWidth / 2` so a + * thick outline stays entirely within the extent rather than clipping at the + * surface edge. + * + * `polygon` is intentionally NOT handled here (deferred until a points-editing + * UX exists); the dispatch never routes a polygon source to this rasterizer in + * this phase. + * + * Zero React, zero import-time side effects. + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import type { RasterizeDeps, RasterizeResult } from './types'; + +type ShapeSource = Extract; +type Ctx = RasterSurface['ctx']; + +/** Builds the rect/ellipse path for `kind` inset by `inset` on every side into the current path. */ +const buildShapePath = (ctx: Ctx, kind: ShapeSource['kind'], width: number, height: number, inset: number): void => { + const w = Math.max(0, width - inset * 2); + const h = Math.max(0, height - inset * 2); + ctx.beginPath(); + if (kind === 'ellipse') { + ctx.ellipse(width / 2, height / 2, w / 2, h / 2, 0, 0, Math.PI * 2); + return; + } + // `rect` (and, defensively, `polygon` which the dispatch never sends here). + ctx.rect(inset, inset, w, h); +}; + +/** + * Draws a shape source onto a surface sized to the source extent. Reuses + * `target` if provided (resizing it to the extent), matching the paint/image + * rasterizer contract. Synchronous work wrapped in a resolved promise so it + * shares the `rasterizeSource` dispatch signature. + */ +export const rasterizeShapeSource = ( + source: ShapeSource, + deps: RasterizeDeps, + target?: RasterSurface +): Promise => { + const width = Math.max(1, Math.round(source.width)); + const height = Math.max(1, Math.round(source.height)); + + const surface = target ?? deps.backend.createSurface(width, height); + if (surface.width !== width || surface.height !== height) { + surface.resize(width, height); + } + const { ctx } = surface; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, width, height); + + if (source.fill) { + ctx.fillStyle = source.fill; + buildShapePath(ctx, source.kind, width, height, 0); + ctx.fill(); + } + + if (source.stroke && source.strokeWidth > 0) { + ctx.strokeStyle = source.stroke; + ctx.lineWidth = source.strokeWidth; + // Inset by half the stroke width so the (centered) stroke stays inside the extent. + buildShapePath(ctx, source.kind, width, height, source.strokeWidth / 2); + ctx.stroke(); + } + + return Promise.resolve({ rect: { height, width, x: 0, y: 0 }, surface }); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/textRasterizer.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/textRasterizer.test.ts new file mode 100644 index 00000000000..d0556ace40c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/textRasterizer.test.ts @@ -0,0 +1,133 @@ +import type { RasterCallLogEntry, StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; + +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it } from 'vitest'; + +import type { TextSource } from './textRasterizer'; +import type { RasterizeDeps } from './types'; + +import { estimateTextExtent, rasterizeTextSource, TEXT_CHAR_WIDTH_FACTOR, textFontString } from './textRasterizer'; + +const makeDeps = (): RasterizeDeps => { + const backend = createTestStubRasterBackend(); + return { + backend, + documentSize: { height: 200, width: 300 }, + resolver: () => Promise.resolve(new Blob()), + store: createLayerCacheStore(backend), + }; +}; + +const ops = (surface: StubRasterSurface): string[] => surface.callLog.map((e) => e.op); +const entries = (surface: StubRasterSurface, op: string): RasterCallLogEntry[] => + surface.callLog.filter((e) => e.op === op); + +const text = (over: Partial = {}): TextSource => ({ + align: 'left', + color: '#112233', + content: 'hello', + fontFamily: 'Inter', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.5, + type: 'text', + ...over, +}); + +// The stub's deterministic metric: width = chars × fontSizePx × TEXT_CHAR_WIDTH_FACTOR. +const stubWidth = (chars: number, fontSize: number): number => Math.ceil(chars * fontSize * TEXT_CHAR_WIDTH_FACTOR); + +describe('rasterizeTextSource — measurement + extent', () => { + it('sizes the surface to the measured block (widest line × fontSize×0.6, lines × fontSize×lineHeight)', async () => { + const deps = makeDeps(); + const source = text({ content: 'hello', fontSize: 20, lineHeight: 1.5 }); + const surface = (await rasterizeTextSource(source, deps)).surface as StubRasterSurface; + expect(surface.width).toBe(stubWidth(5, 20)); // 'hello' = 5 chars → 60 + expect(surface.height).toBe(Math.ceil(1 * 20 * 1.5)); // one line → 30 + }); + + it('agrees with the pure estimateTextExtent (cache-size stability)', async () => { + const deps = makeDeps(); + const source = text({ content: 'abc\ndefgh', fontSize: 16, lineHeight: 1.2 }); + const surface = (await rasterizeTextSource(source, deps)).surface as StubRasterSurface; + const estimate = estimateTextExtent(source); + expect(surface.width).toBe(estimate.width); + expect(surface.height).toBe(estimate.height); + }); + + it('clears before drawing and applies the font + fill color', async () => { + const deps = makeDeps(); + const surface = (await rasterizeTextSource(text({ color: '#abcdef' }), deps)).surface as StubRasterSurface; + expect(ops(surface)).toContain('clearRect'); + expect( + surface.callLog.some((e) => e.op === 'set' && e.args[0] === 'font' && e.args[1] === textFontString(text())) + ).toBe(true); + expect(surface.callLog.some((e) => e.op === 'set' && e.args[0] === 'fillStyle' && e.args[1] === '#abcdef')).toBe( + true + ); + }); +}); + +describe('rasterizeTextSource — line breaks', () => { + it('draws one fillText per manual line, stepped by fontSize×lineHeight', async () => { + const deps = makeDeps(); + const surface = (await rasterizeTextSource(text({ content: 'a\nbb\nccc', fontSize: 10, lineHeight: 2 }), deps)) + .surface as StubRasterSurface; + const fills = entries(surface, 'fillText'); + expect(fills).toHaveLength(3); + // step = 10 × 2 = 20; y anchors at 0, 20, 40. + expect(fills.map((e) => e.args[2])).toEqual([0, 20, 40]); + expect(fills.map((e) => e.args[0])).toEqual(['a', 'bb', 'ccc']); + // Height covers all three lines. + expect(surface.height).toBe(60); + }); +}); + +describe('rasterizeTextSource — alignment', () => { + it('left-aligns at x=0', async () => { + const deps = makeDeps(); + const surface = (await rasterizeTextSource(text({ align: 'left' }), deps)).surface as StubRasterSurface; + expect(surface.callLog.some((e) => e.op === 'set' && e.args[0] === 'textAlign' && e.args[1] === 'left')).toBe(true); + expect(entries(surface, 'fillText')[0]?.args[1]).toBe(0); + }); + + it('center-aligns at x=width/2', async () => { + const deps = makeDeps(); + const source = text({ align: 'center', content: 'hello', fontSize: 20 }); + const surface = (await rasterizeTextSource(source, deps)).surface as StubRasterSurface; + expect(surface.callLog.some((e) => e.op === 'set' && e.args[0] === 'textAlign' && e.args[1] === 'center')).toBe( + true + ); + expect(entries(surface, 'fillText')[0]?.args[1]).toBe(stubWidth(5, 20) / 2); + }); + + it('right-aligns at x=width', async () => { + const deps = makeDeps(); + const source = text({ align: 'right', content: 'hello', fontSize: 20 }); + const surface = (await rasterizeTextSource(source, deps)).surface as StubRasterSurface; + expect(surface.callLog.some((e) => e.op === 'set' && e.args[0] === 'textAlign' && e.args[1] === 'right')).toBe( + true + ); + expect(entries(surface, 'fillText')[0]?.args[1]).toBe(stubWidth(5, 20)); + }); +}); + +describe('rasterizeTextSource — empty + reuse', () => { + it('produces a minimal 1×lineHeight surface for empty content', async () => { + const deps = makeDeps(); + const surface = (await rasterizeTextSource(text({ content: '', fontSize: 20, lineHeight: 1.5 }), deps)) + .surface as StubRasterSurface; + expect(surface.width).toBe(1); + expect(surface.height).toBe(30); + }); + + it('resizes and reuses a provided target surface', async () => { + const deps = makeDeps(); + const target = deps.backend.createSurface(5, 5); + const source = text({ content: 'hello', fontSize: 20 }); + const { surface } = await rasterizeTextSource(source, deps, target); + expect(surface).toBe(target); + expect(surface.width).toBe(stubWidth(5, 20)); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/textRasterizer.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/textRasterizer.ts new file mode 100644 index 00000000000..63a57d3a335 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/textRasterizer.ts @@ -0,0 +1,136 @@ +/** + * Rasterizes a `text` layer source. Text layers are PARAMETRIC and + * EDITABLE-FOREVER: their pixels are derived from the source params (`content`, + * `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `align`, `color`) rather + * than a persisted bitmap, so a style/content edit re-renders for free — the + * headline upgrade over legacy's rasterized (frozen) text. + * + * Layout is deliberately SIMPLE (Risk 8): manual line breaks on `\n`, a + * `lineHeight` multiplier over `fontSize`, and per-line horizontal alignment + * within the measured block width. There is NO shaping engine — no BiDi, no + * complex-script clustering, no automatic word wrap, no kerning beyond what the + * platform `fillText` applies. Metrics come through the {@link RasterSurface} + * `ctx` seam (`measureText`), so node tests run on the stub's deterministic + * `width = chars × fontSizePx × 0.6` (see `raster.testStub.ts`). + * + * Extent semantics: like a shape, the surface is sized to the text BLOCK's own + * measured `width`×`height` (its layer-local extent, top-left origin); the + * compositor applies the layer transform (position/scale) when drawing. A layer + * with an empty `content` still produces a minimal 1×lineHeight surface so its + * transform/anchor stays meaningful. + * + * Font loading (late web-font availability) is handled by the ENGINE, not here: + * this rasterizer draws with whatever metrics `ctx` currently reports, and the + * engine re-rasterizes when a pending font resolves (see `render/fontLoader.ts`). + * + * Zero React, zero import-time side effects. + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import type { RasterizeDeps, RasterizeResult } from './types'; + +/** A `text` layer source. */ +export type TextSource = Extract; +type Ctx = RasterSurface['ctx']; + +/** + * Per-character horizontal advance as a fraction of the font's pixel size, used + * by the pure {@link estimateTextExtent}. It matches the test stub's + * `measureText` factor so a text layer's estimated extent and its + * stub-measured surface size agree exactly in node tests. In the browser the + * true `measureText` governs the rendered surface; the estimate is only a + * pre-measure cache-size / bounds approximation. + */ +export const TEXT_CHAR_WIDTH_FACTOR = 0.6; + +/** The CSS `font` shorthand for a text source (`" px "`). */ +export const textFontString = (source: TextSource): string => + `${source.fontWeight} ${source.fontSize}px ${source.fontFamily}`; + +/** Splits a text source's content into lines on `\n`; always at least one (possibly empty) line. */ +export const textLines = (content: string): string[] => content.split('\n'); + +/** The line-box height in document px for a source (`fontSize × lineHeight`). */ +const lineHeightPx = (source: TextSource): number => source.fontSize * source.lineHeight; + +/** + * A pure, DOM-free estimate of a text block's unscaled pixel extent, matching + * the stub's deterministic metrics (`TEXT_CHAR_WIDTH_FACTOR`). Used by the pure + * geometry helpers (`sources.ts`) to size caches / bounds before a real + * `measureText` runs; the rasterizer resizes the surface to the precise + * measured size, so a browser mismatch only affects the initial cache size. + */ +export const estimateTextExtent = (source: TextSource): { width: number; height: number } => { + const lines = textLines(source.content); + const widest = lines.reduce((max, line) => Math.max(max, line.length), 0); + return { + height: Math.max(1, Math.ceil(lines.length * lineHeightPx(source))), + width: Math.max(1, Math.ceil(widest * source.fontSize * TEXT_CHAR_WIDTH_FACTOR)), + }; +}; + +/** Measures the block extent through the seam's `ctx` (font must be set first). */ +const measureBlock = (ctx: Ctx, source: TextSource, lines: string[]): { width: number; height: number } => { + let widest = 0; + for (const line of lines) { + widest = Math.max(widest, ctx.measureText(line).width); + } + return { + height: Math.max(1, Math.ceil(lines.length * lineHeightPx(source))), + width: Math.max(1, Math.ceil(widest)), + }; +}; + +/** + * Draws a text source into a surface sized to the measured text block, reusing + * `target` if provided (resizing it to the measured extent), matching the + * paint/image/shape rasterizer contract. Synchronous work wrapped in a resolved + * promise so it shares the `rasterizeSource` dispatch signature. + */ +export const rasterizeTextSource = ( + source: TextSource, + deps: RasterizeDeps, + target?: RasterSurface +): Promise => { + const lines = textLines(source.content); + const font = textFontString(source); + + // Measure on the target's own ctx (or a fresh surface) with the font applied. + const surface = target ?? deps.backend.createSurface(1, 1); + surface.ctx.font = font; + const { height, width } = measureBlock(surface.ctx, source, lines); + + if (surface.width !== width || surface.height !== height) { + surface.resize(width, height); + } + const { ctx } = surface; + // A resize resets the browser context state, so (re)apply the transform, font, + // and paint state AFTER resizing. + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, width, height); + ctx.font = font; + ctx.textBaseline = 'top'; + ctx.fillStyle = source.color; + + // Horizontal alignment within the block width, via the canvas text anchor. + let anchorX = 0; + if (source.align === 'center') { + ctx.textAlign = 'center'; + anchorX = width / 2; + } else if (source.align === 'right') { + ctx.textAlign = 'right'; + anchorX = width; + } else { + ctx.textAlign = 'left'; + anchorX = 0; + } + + const step = lineHeightPx(source); + for (let i = 0; i < lines.length; i++) { + ctx.fillText(lines[i] ?? '', anchorX, i * step); + } + + return Promise.resolve({ rect: { height, width, x: 0, y: 0 }, surface }); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/types.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/types.ts new file mode 100644 index 00000000000..1e71ad666ec --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/types.ts @@ -0,0 +1,51 @@ +/** + * Shared vocabulary for the source rasterizers. + * + * Type-only module (no runtime), so it can be imported by both the dispatch + * (`rasterizeSource`) and the per-source rasterizers without any import + * cycle. Zero React, zero side effects. + */ + +import type { DecodedBitmapPool } from '@workbench/canvas-engine/render/decodedBitmapPool'; +import type { LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; + +/** + * Resolves a persisted image asset (referenced by name in the document) to a + * `Blob` for decoding. The DOM implementation (deriving a URL and fetching) + * ships with the React shell task; the engine only depends on this seam so it + * stays node-testable. + */ +export type ImageResolver = (imageName: string, signal?: AbortSignal) => Promise; + +/** + * The result of rasterizing a source: the surface holding its pixels plus the + * content `rect` those pixels occupy in the layer's LOCAL coordinate space. For + * origin-anchored sources (image / shape / text / gradient) the rect origin is + * `(0, 0)`; a `paint` source places its bitmap at the persisted `offset`. + */ +export interface RasterizeResult { + surface: RasterSurface; + rect: Rect; +} + +/** Dependencies shared by the source rasterizers. */ +export interface RasterizeDeps { + /** Surface + bitmap factory seam. */ + backend: RasterBackend; + /** Fetches image blobs by name for decoding. */ + resolver: ImageResolver; + /** Cancels pending image resolution and prevents decode/cache publication. */ + signal?: AbortSignal; + /** Holds the decoded-bitmap cache (keyed by image name). */ + store: LayerCacheStore; + /** Coalesces concurrent decodes and owns decoded pixels only for the duration of a rasterization. */ + bitmapPool?: DecodedBitmapPool; + /** + * Document pixel size. Layers are content-sized, so this only backs the + * legacy default for gradients that predate the explicit extent field (they + * were document-sized by construction and must render identically). + */ + documentSize: { width: number; height: number }; +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/scheduler.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/scheduler.test.ts new file mode 100644 index 00000000000..6de196fb26f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/scheduler.test.ts @@ -0,0 +1,214 @@ +import type { RenderFlags } from '@workbench/canvas-engine/types'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createRenderScheduler } from './scheduler'; + +/** A controllable fake rAF: `flush()` runs the pending callback, if any. */ +const createFakeRaf = () => { + let nextHandle = 1; + const callbacks = new Map(); + return { + cancelFrame: (handle: number): void => { + callbacks.delete(handle); + }, + /** Runs all currently-queued frame callbacks (like the browser firing a frame). */ + flush: (): void => { + const queued = [...callbacks.entries()]; + callbacks.clear(); + for (const [, cb] of queued) { + cb(performance.now()); + } + }, + pendingCount: (): number => callbacks.size, + requestFrame: (cb: FrameRequestCallback): number => { + const handle = nextHandle++; + callbacks.set(handle, cb); + return handle; + }, + }; +}; + +describe('createRenderScheduler', () => { + it('coalesces multiple invalidations into a single render with merged flags', () => { + const raf = createFakeRaf(); + const render = vi.fn<(flags: RenderFlags) => void>(); + const scheduler = createRenderScheduler({ cancelFrame: raf.cancelFrame, render, requestFrame: raf.requestFrame }); + + scheduler.invalidate({ view: true }); + scheduler.invalidate({ layers: ['a'] }); + scheduler.invalidate({ layers: ['b'] }); + scheduler.invalidate({ overlay: true }); + + // Only one frame is scheduled despite four invalidations. + expect(raf.pendingCount()).toBe(1); + expect(render).not.toHaveBeenCalled(); + + raf.flush(); + + expect(render).toHaveBeenCalledTimes(1); + const flags = render.mock.calls[0]![0]; + expect(flags.view).toBe(true); + expect(flags.overlay).toBe(true); + expect(flags.all).toBe(false); + expect([...flags.layers].sort()).toEqual(['a', 'b']); + }); + + it('resets flags after the render callback runs', () => { + const raf = createFakeRaf(); + const render = vi.fn<(flags: RenderFlags) => void>(); + const scheduler = createRenderScheduler({ cancelFrame: raf.cancelFrame, render, requestFrame: raf.requestFrame }); + + scheduler.invalidate({ layers: ['a'], view: true }); + raf.flush(); + + scheduler.invalidate({ layers: ['b'] }); + raf.flush(); + + expect(render).toHaveBeenCalledTimes(2); + const second = render.mock.calls[1]![0]; + // No leftover 'a' or view flag from the first frame. + expect(second.view).toBe(false); + expect([...second.layers]).toEqual(['b']); + }); + + it('does not schedule a new frame when nothing was invalidated', () => { + const raf = createFakeRaf(); + const render = vi.fn<(flags: RenderFlags) => void>(); + createRenderScheduler({ cancelFrame: raf.cancelFrame, render, requestFrame: raf.requestFrame }); + + expect(raf.pendingCount()).toBe(0); + raf.flush(); + expect(render).not.toHaveBeenCalled(); + }); + + it('accumulates invalidations while paused and flushes them on resume', () => { + const raf = createFakeRaf(); + const render = vi.fn<(flags: RenderFlags) => void>(); + const scheduler = createRenderScheduler({ cancelFrame: raf.cancelFrame, render, requestFrame: raf.requestFrame }); + + scheduler.pause(); + scheduler.invalidate({ layers: ['a'] }); + scheduler.invalidate({ view: true }); + + // Nothing scheduled while paused. + expect(raf.pendingCount()).toBe(0); + + scheduler.resume(); + expect(raf.pendingCount()).toBe(1); + raf.flush(); + + expect(render).toHaveBeenCalledTimes(1); + const flags = render.mock.calls[0]![0]; + expect(flags.view).toBe(true); + expect([...flags.layers]).toEqual(['a']); + }); + + it('pause cancels an already-scheduled frame; resume reschedules the same pending flags', () => { + const raf = createFakeRaf(); + const render = vi.fn<(flags: RenderFlags) => void>(); + const scheduler = createRenderScheduler({ cancelFrame: raf.cancelFrame, render, requestFrame: raf.requestFrame }); + + scheduler.invalidate({ view: true }); + expect(raf.pendingCount()).toBe(1); + + scheduler.pause(); + expect(raf.pendingCount()).toBe(0); + raf.flush(); + expect(render).not.toHaveBeenCalled(); + + scheduler.resume(); + raf.flush(); + expect(render).toHaveBeenCalledTimes(1); + expect(render.mock.calls[0]![0].view).toBe(true); + }); + + it('resume with no pending work does not schedule a frame', () => { + const raf = createFakeRaf(); + const render = vi.fn<(flags: RenderFlags) => void>(); + const scheduler = createRenderScheduler({ cancelFrame: raf.cancelFrame, render, requestFrame: raf.requestFrame }); + + scheduler.pause(); + scheduler.resume(); + expect(raf.pendingCount()).toBe(0); + }); + + it('dispose cancels a pending frame and ignores further invalidations', () => { + const raf = createFakeRaf(); + const cancelFrame = vi.fn(raf.cancelFrame); + const render = vi.fn<(flags: RenderFlags) => void>(); + const scheduler = createRenderScheduler({ cancelFrame, render, requestFrame: raf.requestFrame }); + + scheduler.invalidate({ view: true }); + expect(raf.pendingCount()).toBe(1); + + scheduler.dispose(); + expect(cancelFrame).toHaveBeenCalledTimes(1); + expect(raf.pendingCount()).toBe(0); + + scheduler.invalidate({ all: true }); + raf.flush(); + expect(render).not.toHaveBeenCalled(); + expect(raf.pendingCount()).toBe(0); + }); + + it('invalidations from within the render callback schedule a fresh frame', () => { + const raf = createFakeRaf(); + let reentered = false; + const scheduler = createRenderScheduler({ + cancelFrame: raf.cancelFrame, + render: () => { + if (!reentered) { + reentered = true; + scheduler.invalidate({ overlay: true }); + } + }, + requestFrame: raf.requestFrame, + }); + + scheduler.invalidate({ view: true }); + raf.flush(); + // The re-entrant invalidate scheduled another frame rather than being lost. + expect(raf.pendingCount()).toBe(1); + }); + + it('retains pending invalidations and retries when the host frame scheduler throws', () => { + const raf = createFakeRaf(); + const render = vi.fn<(flags: RenderFlags) => void>(); + let shouldThrow = true; + const scheduler = createRenderScheduler({ + cancelFrame: raf.cancelFrame, + render, + requestFrame: (callback) => { + if (shouldThrow) { + throw new Error('host scheduler unavailable'); + } + return raf.requestFrame(callback); + }, + }); + + expect(() => scheduler.invalidate({ view: true })).not.toThrow(); + expect(raf.pendingCount()).toBe(0); + + shouldThrow = false; + scheduler.invalidate({ overlay: true }); + raf.flush(); + + expect(render).toHaveBeenCalledOnce(); + expect(render.mock.calls[0]![0]).toMatchObject({ overlay: true, view: true }); + }); + + it('reflects paused state via isPaused', () => { + const raf = createFakeRaf(); + const scheduler = createRenderScheduler({ + cancelFrame: raf.cancelFrame, + render: () => {}, + requestFrame: raf.requestFrame, + }); + expect(scheduler.isPaused).toBe(false); + scheduler.pause(); + expect(scheduler.isPaused).toBe(true); + scheduler.resume(); + expect(scheduler.isPaused).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/scheduler.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/scheduler.ts new file mode 100644 index 00000000000..1e8ba1e1a48 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/scheduler.ts @@ -0,0 +1,170 @@ +/** + * A single-rAF render scheduler. + * + * Consumers call `invalidate(...)` many times per frame; the scheduler + * coalesces those into one pending `RenderFlags` set and runs the injected + * `render` callback at most once per animation frame. The rAF driver is + * injectable so the scheduler can be driven deterministically in node tests. + * + * Zero React, zero import-time side effects; the only DOM touch point is the + * default `requestFrame`/`cancelFrame`, which are resolved lazily at call + * time (never at import) and are always overridden in tests. + */ + +import type { RenderFlags } from '@workbench/canvas-engine/types'; + +/** The partial invalidation payload accepted by {@link RenderScheduler.invalidate}. */ +export interface InvalidatePayload { + /** The viewport transform (pan/zoom) changed. */ + view?: true; + /** Ids of layers whose pixel content or transform changed. */ + layers?: string[]; + /** Interaction overlays (selection, cursors, guides) changed. */ + overlay?: true; + /** Force a full repaint next frame. */ + all?: true; +} + +/** Dependencies for {@link createRenderScheduler}; the rAF pair is injectable for tests. */ +export interface RenderSchedulerDeps { + /** Invoked once per scheduled frame with the coalesced flags. */ + render: (flags: RenderFlags) => void; + /** Defaults to `globalThis.requestAnimationFrame`. */ + requestFrame?: (callback: FrameRequestCallback) => number; + /** Defaults to `globalThis.cancelAnimationFrame`. */ + cancelFrame?: (handle: number) => void; +} + +/** The imperative scheduler handle returned by {@link createRenderScheduler}. */ +export interface RenderScheduler { + /** Merge a partial invalidation into the pending flags and schedule a frame. */ + invalidate(payload: InvalidatePayload): void; + /** Suspend frame scheduling (e.g. widget detach); invalidations still accumulate. */ + pause(): void; + /** Resume scheduling; flushes any invalidations accumulated while paused. */ + resume(): void; + /** True while paused. */ + readonly isPaused: boolean; + /** Cancel any pending frame and stop accepting further work. */ + dispose(): void; +} + +const createEmptyFlags = (): RenderFlags => ({ + all: false, + layers: new Set(), + overlay: false, + view: false, +}); + +const hasPending = (flags: RenderFlags): boolean => flags.all || flags.view || flags.overlay || flags.layers.size > 0; + +const defaultRequestFrame = (callback: FrameRequestCallback): number => globalThis.requestAnimationFrame(callback); + +const defaultCancelFrame = (handle: number): void => { + globalThis.cancelAnimationFrame(handle); +}; + +/** + * Creates a coalescing, single-frame render scheduler. See module docs for + * the coalescing and pause/resume semantics. + */ +export const createRenderScheduler = (deps: RenderSchedulerDeps): RenderScheduler => { + const requestFrame = deps.requestFrame ?? defaultRequestFrame; + const cancelFrame = deps.cancelFrame ?? defaultCancelFrame; + + let pending = createEmptyFlags(); + let frameHandle: number | null = null; + let paused = false; + let disposed = false; + + const runFrame = (): void => { + frameHandle = null; + if (disposed) { + return; + } + // Snapshot then reset before invoking render, so invalidations made from + // within the render callback accumulate into a fresh frame rather than + // being wiped by the post-render reset. + const flags = pending; + pending = createEmptyFlags(); + deps.render(flags); + }; + + const schedule = (): void => { + if (disposed || paused || frameHandle !== null) { + return; + } + if (!hasPending(pending)) { + return; + } + try { + frameHandle = requestFrame(runFrame); + } catch { + // Rendering is ancillary to the document mutation that requested it. A + // faulty host scheduler must not make that already-applied mutation look + // failed; retain the pending flags so a later invalidation can retry. + frameHandle = null; + } + }; + + const invalidate = (payload: InvalidatePayload): void => { + if (disposed) { + return; + } + if (payload.all) { + pending.all = true; + } + if (payload.view) { + pending.view = true; + } + if (payload.overlay) { + pending.overlay = true; + } + if (payload.layers) { + for (const id of payload.layers) { + pending.layers.add(id); + } + } + schedule(); + }; + + const pause = (): void => { + if (disposed || paused) { + return; + } + paused = true; + if (frameHandle !== null) { + cancelFrame(frameHandle); + frameHandle = null; + } + }; + + const resume = (): void => { + if (disposed || !paused) { + return; + } + paused = false; + schedule(); + }; + + const dispose = (): void => { + if (disposed) { + return; + } + disposed = true; + if (frameHandle !== null) { + cancelFrame(frameHandle); + frameHandle = null; + } + }; + + return { + dispose, + invalidate, + get isPaused() { + return paused; + }, + pause, + resume, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/surfaceBudget.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/surfaceBudget.test.ts new file mode 100644 index 00000000000..29f04bc8065 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/surfaceBudget.test.ts @@ -0,0 +1,46 @@ +import { createCanvasDiagnostics } from '@workbench/canvas-engine/diagnostics'; +import { describe, expect, it } from 'vitest'; + +import { createDerivedSurfaceCache } from './derivedSurfaceCache'; +import { createLayerCacheStore } from './layerCache'; +import { createTestStubRasterBackend } from './raster.testStub'; +import { enforceSurfaceBudget } from './surfaceBudget'; + +describe('enforceSurfaceBudget', () => { + it('evicts derived surfaces before hidden base surfaces', () => { + const backend = createTestStubRasterBackend(); + const layers = createLayerCacheStore(backend); + const derived = createDerivedSurfaceCache(); + layers.getOrCreate('visible', 10, 10); + layers.getOrCreate('hidden', 10, 10); + const source = backend.createSurface(10, 10); + derived.get({ + create: () => backend.createSurface(10, 10), + kind: 'mask-fill', + layerId: 'visible', + paramsKey: 'red', + source, + sourceVersion: 0, + }); + + const result = enforceSurfaceBudget(layers, derived, ['visible'], 800); + + expect(result.evictedDerivedLayerIds).toEqual(['visible']); + expect(result.evictedBaseLayerIds).toEqual([]); + expect(layers.get('hidden')).toBeDefined(); + }); + + it('protects visible base pixels and reports their overage', () => { + const backend = createTestStubRasterBackend(); + const layers = createLayerCacheStore(backend); + const derived = createDerivedSurfaceCache(); + const diagnostics = createCanvasDiagnostics(true); + layers.getOrCreate('visible', 20, 20); + + const result = enforceSurfaceBudget(layers, derived, ['visible'], 400, diagnostics); + + expect(result.overBudgetVisibleBaseBytes).toBe(1_200); + expect(layers.get('visible')).toBeDefined(); + expect(diagnostics.snapshot().overBudgetVisibleBaseBytes).toBe(1_200); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/surfaceBudget.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/surfaceBudget.ts new file mode 100644 index 00000000000..1bb7da1d41c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/surfaceBudget.ts @@ -0,0 +1,32 @@ +import type { CanvasDiagnostics } from '@workbench/canvas-engine/diagnostics'; + +import type { DerivedSurfaceCache } from './derivedSurfaceCache'; +import type { LayerCacheStore } from './layerCache'; + +export interface SurfaceBudgetResult { + readonly evictedDerivedLayerIds: string[]; + readonly evictedBaseLayerIds: string[]; + readonly overBudgetVisibleBaseBytes: number; +} + +export const enforceSurfaceBudget = ( + base: LayerCacheStore, + derived: DerivedSurfaceCache, + visibleLayerIds: Iterable, + budgetBytes: number, + diagnostics?: CanvasDiagnostics +): SurfaceBudgetResult => { + const budget = Math.max(0, budgetBytes); + const baseBytesBefore = base.byteSize(); + const derivedBudget = Math.max(0, budget - baseBytesBefore); + const evictedDerivedLayerIds = derived.evictToBudget(derivedBudget); + + const baseBudget = Math.max(0, budget - derived.byteSize()); + const evictedBaseLayerIds = base.evictHidden(visibleLayerIds, baseBudget); + const overBudgetVisibleBaseBytes = Math.max(0, base.byteSize() + derived.byteSize() - budget); + if (overBudgetVisibleBaseBytes > 0) { + diagnostics?.add('overBudgetVisibleBaseBytes', overBudgetVisibleBaseBytes); + } + + return { evictedBaseLayerIds, evictedDerivedLayerIds, overBudgetVisibleBaseBytes }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/thumbnail.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/thumbnail.test.ts new file mode 100644 index 00000000000..1bdd7ba10b5 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/thumbnail.test.ts @@ -0,0 +1,202 @@ +import type { CanvasLayerContract, CanvasLayerSourceContract } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import { + fitThumbnailSize, + getLayerThumbnailFallbackRenderState, + getLayerThumbnailDisplayKey, + nextLayerThumbnailFallbackStage, + resolveLayerThumbnailImageRef, +} from './thumbnail'; + +const rasterLayer = (source: CanvasLayerSourceContract): CanvasLayerContract => + ({ + adjustments: { brightness: 0, contrast: 0, saturation: 0 }, + blendMode: 'normal', + id: 'raster', + isEnabled: true, + isLocked: false, + name: 'Raster', + opacity: 1, + source, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }) as CanvasLayerContract; + +const controlLayer = (source: CanvasLayerSourceContract): CanvasLayerContract => + ({ + adapter: { + beginEndStepPct: [0, 1], + controlMode: null, + kind: 'controlnet', + model: null, + weight: 1, + }, + blendMode: 'normal', + id: 'control', + isEnabled: true, + isLocked: false, + name: 'Control', + opacity: 1, + source, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: true, + }) as CanvasLayerContract; + +const maskLayer = (type: 'inpaint_mask' | 'regional_guidance', imageName: string | null): CanvasLayerContract => + ({ + autoNegative: true, + blendMode: 'normal', + id: type, + isEnabled: true, + isLocked: false, + mask: { + bitmap: imageName ? { height: 20, imageName, width: 30 } : null, + fill: { color: '#e07575', style: 'diagonal' }, + }, + name: type, + negativePrompt: null, + opacity: 1, + positivePrompt: null, + referenceImages: [], + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type, + }) as CanvasLayerContract; + +describe('fitThumbnailSize', () => { + it('scales a large landscape surface to fit the box width', () => { + expect(fitThumbnailSize(200, 100, 96)).toEqual({ height: 48, width: 96 }); + }); + + it('scales a large portrait surface to fit the box height', () => { + expect(fitThumbnailSize(100, 200, 96)).toEqual({ height: 96, width: 48 }); + }); + + it('keeps a square surface square', () => { + expect(fitThumbnailSize(300, 300, 96)).toEqual({ height: 96, width: 96 }); + }); + + it('never upscales a surface smaller than the box', () => { + expect(fitThumbnailSize(40, 20, 96)).toEqual({ height: 20, width: 40 }); + }); + + it('clamps a fitted dimension to at least 1px', () => { + expect(fitThumbnailSize(1000, 1, 96)).toEqual({ height: 1, width: 96 }); + }); + + it('returns a zero size for a degenerate source or box', () => { + expect(fitThumbnailSize(0, 100, 96)).toEqual({ height: 0, width: 0 }); + expect(fitThumbnailSize(100, 100, 0)).toEqual({ height: 0, width: 0 }); + }); +}); + +describe('resolveLayerThumbnailImageRef', () => { + const image = { height: 20, imageName: 'image', width: 30 }; + const bitmap = { height: 20, imageName: 'bitmap', width: 30 }; + + it.each([ + ['raster image', rasterLayer({ image, type: 'image' }), image], + ['control image', controlLayer({ image, type: 'image' }), image], + ['raster paint', rasterLayer({ bitmap, type: 'paint' }), bitmap], + ['control paint', controlLayer({ bitmap, type: 'paint' }), bitmap], + ['inpaint mask', maskLayer('inpaint_mask', 'inpaint'), expect.objectContaining({ imageName: 'inpaint' })], + [ + 'regional guidance mask', + maskLayer('regional_guidance', 'regional'), + expect.objectContaining({ imageName: 'regional' }), + ], + ])('resolves the %s image reference', (_name, layer, expected) => { + expect(resolveLayerThumbnailImageRef(layer as CanvasLayerContract)).toEqual(expected); + }); + + it.each([ + ['empty raster paint', rasterLayer({ bitmap: null, type: 'paint' })], + ['empty control paint', controlLayer({ bitmap: null, type: 'paint' })], + ['empty inpaint mask', maskLayer('inpaint_mask', null)], + ['empty regional guidance mask', maskLayer('regional_guidance', null)], + [ + 'text source', + rasterLayer({ + align: 'left', + color: '#fff', + content: 'text', + fontFamily: 'Inter', + fontSize: 16, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + }), + ], + [ + 'shape source', + rasterLayer({ fill: '#fff', height: 10, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 10 }), + ], + ['gradient source', rasterLayer({ angle: 0, kind: 'linear', stops: [], type: 'gradient' })], + ])('returns null for an %s', (_name, layer) => { + expect(resolveLayerThumbnailImageRef(layer as CanvasLayerContract)).toBeNull(); + }); + + it('returns null instead of throwing for a malformed source', () => { + const malformed = rasterLayer({ image, type: 'image' }); + Object.defineProperty(malformed, 'source', { + get: () => { + throw new Error('invalid source'); + }, + }); + expect(resolveLayerThumbnailImageRef(malformed)).toBeNull(); + }); +}); + +describe('getLayerThumbnailDisplayKey', () => { + it('changes only for raster adjustments, mask fill, and control transparency', () => { + const raster = rasterLayer({ image: { height: 10, imageName: 'r', width: 10 }, type: 'image' }); + const control = controlLayer({ image: { height: 10, imageName: 'c', width: 10 }, type: 'image' }); + const mask = maskLayer('inpaint_mask', 'm'); + + expect(getLayerThumbnailDisplayKey({ ...raster, name: 'renamed' })).toBe(getLayerThumbnailDisplayKey(raster)); + expect(getLayerThumbnailDisplayKey({ ...raster, opacity: 0.5 })).not.toBe(getLayerThumbnailDisplayKey(raster)); + expect(getLayerThumbnailDisplayKey({ ...control, opacity: 0.5 })).not.toBe(getLayerThumbnailDisplayKey(control)); + expect(getLayerThumbnailDisplayKey({ ...mask, opacity: 0.5 })).not.toBe(getLayerThumbnailDisplayKey(mask)); + expect( + getLayerThumbnailDisplayKey({ + ...raster, + adjustments: { brightness: 0.2, contrast: 0, saturation: 0 }, + } as CanvasLayerContract) + ).not.toBe(getLayerThumbnailDisplayKey(raster)); + expect(getLayerThumbnailDisplayKey({ ...control, withTransparencyEffect: false } as CanvasLayerContract)).not.toBe( + getLayerThumbnailDisplayKey(control) + ); + expect( + getLayerThumbnailDisplayKey({ + ...mask, + mask: { ...('mask' in mask ? mask.mask : {}), fill: { color: '#00ff00', style: 'solid' } }, + } as CanvasLayerContract) + ).not.toBe(getLayerThumbnailDisplayKey(mask)); + }); +}); + +describe('nextLayerThumbnailFallbackStage', () => { + it('falls back from backend thumbnail to full image, then to the retry placeholder', () => { + expect(nextLayerThumbnailFallbackStage('thumbnail')).toBe('full'); + expect(nextLayerThumbnailFallbackStage('full')).toBe('failed'); + expect(nextLayerThumbnailFallbackStage('failed')).toBe('failed'); + }); + + it('hides a failed URL fallback and its retry overlay after the engine draws successfully', () => { + const failed = nextLayerThumbnailFallbackStage(nextLayerThumbnailFallbackStage('thumbnail')); + expect(getLayerThumbnailFallbackRenderState(false, failed, false)).toEqual({ + showFallback: true, + showRetry: true, + }); + expect(getLayerThumbnailFallbackRenderState(true, failed, false)).toEqual({ + showFallback: false, + showRetry: false, + }); + expect(getLayerThumbnailFallbackRenderState(true, 'thumbnail', true)).toEqual({ + showFallback: false, + showRetry: false, + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/thumbnail.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/thumbnail.ts new file mode 100644 index 00000000000..2b3d0f24744 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/thumbnail.ts @@ -0,0 +1,88 @@ +/** + * Pure helpers for layer thumbnails. + * + * A layer's cache surface can be any size (up to the document dimensions); a + * thumbnail must fit within a small square box while preserving aspect ratio + * and never upscaling past the source. Extracted from the engine's + * `drawLayerThumbnail` so the arithmetic is unit-testable without a canvas. + * + * Zero React, zero DOM, zero import-time side effects. + */ + +import type { CanvasImageRef, CanvasLayerContract } from '@workbench/types'; + +import { renderableSourceOf } from '@workbench/canvas-engine/document/sources'; + +import { adjustmentsKey } from './adjustments'; + +/** A thumbnail's integer pixel dimensions after fitting into a `maxSize` box. */ +export interface ThumbnailSize { + width: number; + height: number; +} + +export type LayerThumbnailFallbackStage = 'thumbnail' | 'full' | 'failed'; + +/** + * Scales a `srcW`x`srcH` surface to fit inside a `maxSize`x`maxSize` box, + * preserving aspect ratio and never upscaling (scale is clamped to ≤ 1). Both + * returned dimensions are at least 1px. Returns a zero size for a degenerate + * (non-positive) source. + */ +export const fitThumbnailSize = (srcW: number, srcH: number, maxSize: number): ThumbnailSize => { + if (srcW <= 0 || srcH <= 0 || maxSize <= 0) { + return { height: 0, width: 0 }; + } + const scale = Math.min(1, maxSize / srcW, maxSize / srcH); + return { + height: Math.max(1, Math.round(srcH * scale)), + width: Math.max(1, Math.round(srcW * scale)), + }; +}; + +/** + * Resolves the persisted image that can stand in for a layer while its engine + * cache is unavailable. Image and paint sources are handled uniformly through + * `renderableSourceOf`, including the synthetic paint source exposed by masks. + * Empty paint/mask and parametric sources have no persisted image fallback. + */ +export const resolveLayerThumbnailImageRef = (layer: CanvasLayerContract): CanvasImageRef | null => { + try { + const source = renderableSourceOf(layer); + if (source?.type === 'image') { + return source.image; + } + if (source?.type === 'paint') { + return source.bitmap; + } + return null; + } catch { + // Invalid persisted contracts should degrade to the explicit placeholder. + return null; + } +}; + +/** Display-only properties that alter a layer thumbnail without changing its raster cache. */ +export const getLayerThumbnailDisplayKey = (layer: CanvasLayerContract): string => { + if (layer.type === 'raster') { + return `raster:${layer.opacity}:${adjustmentsKey(layer.adjustments)}`; + } + if (layer.type === 'control') { + return `control:${layer.opacity}:${layer.withTransparencyEffect ? 'transparent' : 'opaque'}`; + } + return `mask:${layer.opacity}:${layer.mask.fill.style}:${layer.mask.fill.color}`; +}; + +/** Advances the persisted-image fallback after an image element load failure. */ +export const nextLayerThumbnailFallbackStage = (current: LayerThumbnailFallbackStage): LayerThumbnailFallbackStage => + current === 'thumbnail' ? 'full' : 'failed'; + +/** Pure render policy shared by the thumbnail fallback content and retry overlay. */ +export const getLayerThumbnailFallbackRenderState = ( + drawn: boolean, + fallbackStage: LayerThumbnailFallbackStage, + hasRequestError: boolean +): { showFallback: boolean; showRetry: boolean } => ({ + showFallback: !drawn, + showRetry: !drawn && (hasRequestError || fallbackStage === 'failed'), +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/samCoordinates.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/samCoordinates.ts new file mode 100644 index 00000000000..92929137996 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/samCoordinates.ts @@ -0,0 +1,37 @@ +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; + +const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); + +const isPixelRect = (rect: Rect): boolean => + Number.isFinite(rect.x) && + Number.isFinite(rect.y) && + Number.isInteger(rect.width) && + Number.isInteger(rect.height) && + rect.width > 0 && + rect.height > 0; + +/** Converts a document point to the export's canonical half-open integer pixel domain. */ +export const documentToExportLocalSamPoint = (point: Vec2, exportRect: Rect, clampOutside = false): Vec2 | null => { + if (!isPixelRect(exportRect) || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + return null; + } + if ( + !clampOutside && + (point.x < exportRect.x || + point.x >= exportRect.x + exportRect.width || + point.y < exportRect.y || + point.y >= exportRect.y + exportRect.height) + ) { + return null; + } + return { + x: clamp(Math.round(point.x - exportRect.x), 0, exportRect.width - 1), + y: clamp(Math.round(point.y - exportRect.y), 0, exportRect.height - 1), + }; +}; + +/** Canonicalizes a document point while retaining document-space coordinates. */ +export const canonicalizeDocumentSamPoint = (point: Vec2, exportRect: Rect, clampOutside = false): Vec2 | null => { + const local = documentToExportLocalSamPoint(point, exportRect, clampOutside); + return local ? { x: exportRect.x + local.x, y: exportRect.y + local.y } : null; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/samInteraction.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/samInteraction.ts new file mode 100644 index 00000000000..be75d4616ed --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/samInteraction.ts @@ -0,0 +1,15 @@ +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; + +/** Core interaction-only state for point and bounding-box manipulation. */ +export interface SamVisualInput { + type: 'visual'; + includePoints: Vec2[]; + excludePoints: Vec2[]; + bbox: Rect | null; +} + +export interface SamInteractionState { + input: SamVisualInput; + pointLabel: 'include' | 'exclude'; + sourceRect: Rect; +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/marchingAnts.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/marchingAnts.test.ts new file mode 100644 index 00000000000..7f318967858 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/marchingAnts.test.ts @@ -0,0 +1,136 @@ +import type { StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { Mat2d } from '@workbench/canvas-engine/types'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { createAntsAnimator, drawMarchingAnts } from '@workbench/canvas-engine/selection/marchingAnts'; +import { describe, expect, it, vi } from 'vitest'; + +const IDENTITY: Mat2d = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; +const fakePath = (id: string): Path2D => ({ id }) as unknown as Path2D; + +describe('drawMarchingAnts', () => { + it('strokes each path twice (two-tone), applies a dash and the animated offset', () => { + const surface = createTestStubRasterBackend().createSurface(50, 50) as StubRasterSurface; + drawMarchingAnts(surface.ctx, IDENTITY, { paths: [fakePath('p')], phase: 3 }); + + const log = surface.callLog; + const ops = log.map((e) => e.op); + expect(ops.filter((op) => op === 'stroke')).toHaveLength(2); + expect(ops).toContain('setLineDash'); + expect(ops).toContain('setTransform'); + + const strokeStyles = log.filter((e) => e.op === 'set' && e.args[0] === 'strokeStyle').map((e) => e.args[1]); + expect(strokeStyles).toContain('#000000'); + expect(strokeStyles).toContain('#ffffff'); + // The light run is offset from the dark run so the ants appear to crawl. + const offsets = log.filter((e) => e.op === 'set' && e.args[0] === 'lineDashOffset').map((e) => e.args[1]); + expect(new Set(offsets).size).toBeGreaterThan(1); + }); + + it('is a no-op with no paths', () => { + const surface = createTestStubRasterBackend().createSurface(10, 10) as StubRasterSurface; + drawMarchingAnts(surface.ctx, IDENTITY, { paths: [], phase: 0 }); + expect(surface.callLog).toHaveLength(0); + }); +}); + +/** A controllable rAF pair + clock for driving the animator deterministically. */ +const createDriver = () => { + let nextHandle = 1; + const callbacks = new Map void>(); + let clock = 0; + return { + advance: (ms: number): void => { + clock += ms; + }, + cancelFrame: (handle: number): void => { + callbacks.delete(handle); + }, + /** Runs every queued frame callback once (like the browser firing a frame). */ + flush: (): void => { + const queued = [...callbacks.entries()]; + callbacks.clear(); + for (const [, cb] of queued) { + cb(); + } + }, + now: (): number => clock, + pending: (): number => callbacks.size, + requestFrame: (cb: () => void): number => { + const handle = nextHandle++; + callbacks.set(handle, cb); + return handle; + }, + }; +}; + +describe('createAntsAnimator', () => { + it('steps on the throttle boundary and reschedules each frame while running', () => { + const driver = createDriver(); + const onStep = vi.fn(); + const animator = createAntsAnimator({ + cancelFrame: driver.cancelFrame, + intervalMs: 100, + now: driver.now, + onStep, + requestFrame: driver.requestFrame, + }); + + animator.start(); + expect(animator.isRunning).toBe(true); + expect(driver.pending()).toBe(1); + + // First frame steps immediately (start primes lastStep one interval back). + driver.flush(); + expect(onStep).toHaveBeenCalledTimes(1); + // It rescheduled itself. + expect(driver.pending()).toBe(1); + + // A frame before the interval elapses does not step, but keeps polling. + driver.advance(40); + driver.flush(); + expect(onStep).toHaveBeenCalledTimes(1); + expect(driver.pending()).toBe(1); + + // Crossing the interval steps again. + driver.advance(70); + driver.flush(); + expect(onStep).toHaveBeenCalledTimes(2); + }); + + it('stop cancels the pending frame and halts rescheduling (no timer leak)', () => { + const driver = createDriver(); + const onStep = vi.fn(); + const animator = createAntsAnimator({ + cancelFrame: driver.cancelFrame, + intervalMs: 100, + now: driver.now, + onStep, + requestFrame: driver.requestFrame, + }); + + animator.start(); + animator.stop(); + expect(animator.isRunning).toBe(false); + expect(driver.pending()).toBe(0); + + // Any straggler frame that still fires must not step or reschedule. + driver.advance(1000); + driver.flush(); + expect(onStep).not.toHaveBeenCalled(); + expect(driver.pending()).toBe(0); + }); + + it('start is idempotent (no double scheduling)', () => { + const driver = createDriver(); + const animator = createAntsAnimator({ + cancelFrame: driver.cancelFrame, + now: driver.now, + onStep: vi.fn(), + requestFrame: driver.requestFrame, + }); + animator.start(); + animator.start(); + expect(driver.pending()).toBe(1); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/marchingAnts.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/marchingAnts.ts new file mode 100644 index 00000000000..7870d286f40 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/marchingAnts.ts @@ -0,0 +1,155 @@ +/** + * Marching ants: the animated dashed outline of a pixel selection. + * + * Two concerns live here: + * + * 1. {@link drawMarchingAnts} strokes the selection's committed path list onto + * the overlay surface as a two-tone (black/white) dashed outline. It draws in + * document space (paths are document-space `Path2D`s) via the shared `view` + * transform, scaling line width and dash by `1/scale` so the ants stay a + * constant pixel size at any zoom — the same screen-constant convention the + * rest of the overlay uses. The animated `lineDashOffset` (`phase`) makes the + * white dashes crawl. + * + * 2. {@link createAntsAnimator} drives the `phase` advance. It self-reschedules + * through an injected `requestFrame`/`cancelFrame` pair (the engine passes the + * global rAF, so it rides the same frame clock as the scheduler) and, throttled + * by an injected `now()` to ~a few fps (the legacy look, not 60fps), calls + * `onStep` — the engine advances the phase and requests an OVERLAY-ONLY + * invalidation there, so an ants tick never recomposites layers (Task-22 gate). + * It runs only while started (a selection exists AND the engine is attached) + * and cancels its pending frame on `stop`, so there are no timer leaks on + * detach/deselect/dispose. + * + * Zero React, zero import-time side effects. + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Mat2d } from '@workbench/canvas-engine/types'; + +import { getScale } from '@workbench/canvas-engine/math/mat2d'; + +/** Dash length in screen pixels; the offset advances by {@link ANTS_STEP_PX} per tick. */ +export const ANTS_DASH_PX = 4; +/** Screen-pixel advance of the dash offset per animation step. */ +export const ANTS_STEP_PX = 1; +/** Default step interval (ms) — ~5 fps, the legacy marching-ants cadence. */ +export const ANTS_INTERVAL_MS = 200; + +const ANTS_LINE_WIDTH_PX = 1; +const ANTS_DARK = '#000000'; +const ANTS_LIGHT = '#ffffff'; + +/** What {@link drawMarchingAnts} needs: the committed outlines and the animation phase. */ +export interface MarchingAntsRender { + paths: readonly Path2D[]; + /** Animated dash offset in screen pixels. */ + phase: number; +} + +/** + * Strokes the selection outline(s) as animated two-tone marching ants, in + * document space projected through `view`. A no-op with no paths. + */ +export const drawMarchingAnts = (ctx: RasterSurface['ctx'], view: Mat2d, render: MarchingAntsRender): void => { + if (render.paths.length === 0) { + return; + } + const scale = getScale(view) || 1; + const dash = ANTS_DASH_PX / scale; + const lineWidth = ANTS_LINE_WIDTH_PX / scale; + const offset = render.phase / scale; + + ctx.save(); + ctx.setTransform(view.a, view.b, view.c, view.d, view.e, view.f); + ctx.lineWidth = lineWidth; + + // Dark base run: a continuous dashed outline. + ctx.setLineDash([dash, dash]); + ctx.lineDashOffset = -offset; + ctx.strokeStyle = ANTS_DARK; + for (const path of render.paths) { + ctx.stroke(path); + } + + // Light run, offset by one dash so it fills the gaps and appears to crawl. + ctx.lineDashOffset = -offset + dash; + ctx.strokeStyle = ANTS_LIGHT; + for (const path of render.paths) { + ctx.stroke(path); + } + + ctx.restore(); +}; + +/** The animator handle the engine starts/stops as selection presence + attachment change. */ +export interface AntsAnimator { + /** Begins the animation loop (idempotent). */ + start(): void; + /** Stops the loop and cancels any pending frame (idempotent). */ + stop(): void; + /** True while the loop is running. */ + readonly isRunning: boolean; +} + +/** Dependencies for {@link createAntsAnimator}; all injectable for deterministic tests. */ +export interface AntsAnimatorDeps { + requestFrame(callback: () => void): number; + cancelFrame(handle: number): void; + /** Monotonic clock (ms) used to throttle steps to {@link AntsAnimatorDeps.intervalMs}. */ + now(): number; + /** Called on each throttled step (the engine advances the phase + invalidates the overlay). */ + onStep(): void; + /** Step interval in ms; defaults to {@link ANTS_INTERVAL_MS}. */ + intervalMs?: number; +} + +/** + * Creates a self-rescheduling animation loop that fires `onStep` at most every + * `intervalMs`. It reschedules on every frame while running (to keep polling the + * clock) but only steps on the throttle boundary. + */ +export const createAntsAnimator = (deps: AntsAnimatorDeps): AntsAnimator => { + const intervalMs = deps.intervalMs ?? ANTS_INTERVAL_MS; + let running = false; + let handle: number | null = null; + let lastStep = 0; + + const frame = (): void => { + handle = null; + if (!running) { + return; + } + const t = deps.now(); + if (t - lastStep >= intervalMs) { + lastStep = t; + deps.onStep(); + } + // Reschedule while running (even between steps) so the clock keeps advancing. + if (running) { + handle = deps.requestFrame(frame); + } + }; + + return { + get isRunning() { + return running; + }, + start: () => { + if (running) { + return; + } + running = true; + // Force the first frame to step immediately. + lastStep = deps.now() - intervalMs; + handle = deps.requestFrame(frame); + }, + stop: () => { + running = false; + if (handle !== null) { + deps.cancelFrame(handle); + handle = null; + } + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/maskOutline.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/maskOutline.test.ts new file mode 100644 index 00000000000..229df87c77b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/maskOutline.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { traceMaskOutlinePath } from './maskOutline'; + +/** Builds RGBA data from a row-major alpha grid. */ +const alphaGrid = ( + rows: readonly (readonly number[])[] +): { data: Uint8ClampedArray; height: number; width: number } => { + const height = rows.length; + const width = rows[0]?.length ?? 0; + const data = new Uint8ClampedArray(width * height * 4); + rows.forEach((row, y) => + row.forEach((alpha, x) => { + data[(y * width + x) * 4 + 3] = alpha; + }) + ); + return { data, height, width }; +}; + +const ORIGIN = { x: 0, y: 0 }; + +describe('traceMaskOutlinePath', () => { + it('returns an empty path for an empty or sub-threshold mask', () => { + expect(traceMaskOutlinePath(alphaGrid([]), ORIGIN)).toBe(''); + expect( + traceMaskOutlinePath( + alphaGrid([ + [0, 0], + [0, 0], + ]), + ORIGIN + ) + ).toBe(''); + expect( + traceMaskOutlinePath( + alphaGrid([ + [64, 64], + [64, 64], + ]), + ORIGIN + ) + ).toBe(''); + }); + + it('honors a lower threshold for faint coverage', () => { + const grid = alphaGrid([[64]]); + expect(traceMaskOutlinePath(grid, ORIGIN)).toBe(''); + expect(traceMaskOutlinePath(grid, ORIGIN, 1)).toBe('M 0 0 L 1 0 L 1 1 L 0 1 Z'); + }); + + it('traces a full mask as its bounding rectangle with merged collinear runs', () => { + const grid = alphaGrid([ + [255, 255, 255], + [255, 255, 255], + ]); + expect(traceMaskOutlinePath(grid, { x: 10, y: 20 })).toBe('M 10 20 L 13 20 L 13 22 L 10 22 Z'); + }); + + it('traces a single interior pixel offset by the document-space origin', () => { + const grid = alphaGrid([ + [0, 0, 0], + [0, 255, 0], + [0, 0, 0], + ]); + expect(traceMaskOutlinePath(grid, { x: 7, y: -3 })).toBe('M 8 -2 L 9 -2 L 9 -1 L 8 -1 Z'); + }); + + it('emits a counter-clockwise inner loop for a hole', () => { + const grid = alphaGrid([ + [255, 255, 255], + [255, 0, 255], + [255, 255, 255], + ]); + const path = traceMaskOutlinePath(grid, ORIGIN); + const loops = path.split(' Z').filter((segment) => segment.trim().length > 0); + expect(loops).toHaveLength(2); + expect(path).toContain('M 0 0 L 3 0 L 3 3 L 0 3 Z'); + // The hole loop winds opposite to the outer loop (counter-clockwise), so + // nonzero fill keeps the hole. + expect(path).toContain('M 2 1 L 1 1 L 1 2 L 2 2 Z'); + }); + + it('keeps diagonally touching regions as two separate loops', () => { + const grid = alphaGrid([ + [255, 0], + [0, 255], + ]); + const path = traceMaskOutlinePath(grid, ORIGIN); + const loops = path.split(' Z').filter((segment) => segment.trim().length > 0); + expect(loops).toHaveLength(2); + expect(path).toContain('M 0 0 L 1 0 L 1 1 L 0 1 Z'); + expect(path).toContain('M 1 1 L 2 1 L 2 2 L 1 2 Z'); + }); + + it('traces disjoint regions as independent closed loops', () => { + const grid = alphaGrid([ + [255, 0, 255], + [255, 0, 255], + ]); + const path = traceMaskOutlinePath(grid, ORIGIN); + expect(path).toContain('M 0 0 L 1 0 L 1 2 L 0 2 Z'); + expect(path).toContain('M 2 0 L 3 0 L 3 2 L 2 2 Z'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/maskOutline.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/maskOutline.ts new file mode 100644 index 00000000000..0bc0338db25 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/maskOutline.ts @@ -0,0 +1,143 @@ +/** + * Rectilinear boundary tracing for alpha masks. + * + * Converts a mask's alpha channel into closed pixel-edge contours so the + * marching ants can stroke the mask's true outline instead of its bounding + * rect. Outer boundaries wind clockwise (screen coordinates) and holes wind + * counter-clockwise, so the emitted path also nonzero-fills to the same shape + * it outlines. + */ + +interface MaskAlphaSource { + /** RGBA pixel data (only the alpha channel is read). */ + data: Uint8ClampedArray; + width: number; + height: number; +} + +// Directions: 0 = right, 1 = down, 2 = left, 3 = up (screen coordinates). +const DIRECTION_X = [1, 0, -1, 0] as const; +const DIRECTION_Y = [0, 1, 0, -1] as const; + +/** + * Traces the outline of every alpha region as an SVG path in document space + * (`origin` offsets the mask-local pixel grid). Pixels count as inside at + * `alpha >= threshold`. Returns an empty string for an empty mask. + */ +export const traceMaskOutlinePath = ( + source: MaskAlphaSource, + origin: { x: number; y: number }, + threshold = 128 +): string => { + const { data, height, width } = source; + if (width <= 0 || height <= 0) { + return ''; + } + const inside = (x: number, y: number): boolean => + x >= 0 && y >= 0 && x < width && y < height && data[(y * width + x) * 4 + 3]! >= threshold; + + // Directed boundary edges bucketed by start vertex on the (width+1)×(height+1) + // pixel-corner grid. Travelling each edge keeps the inside on the right, which + // makes outer loops clockwise and holes counter-clockwise. + const stride = width + 1; + const outgoing = new Map(); + const addEdge = (x: number, y: number, direction: number): void => { + const key = y * stride + x; + const bucket = outgoing.get(key); + if (bucket) { + bucket.push(direction); + } else { + outgoing.set(key, [direction]); + } + }; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if (!inside(x, y)) { + continue; + } + if (!inside(x, y - 1)) { + addEdge(x, y, 0); + } + if (!inside(x + 1, y)) { + addEdge(x + 1, y, 1); + } + if (!inside(x, y + 1)) { + addEdge(x + 1, y + 1, 2); + } + if (!inside(x - 1, y)) { + addEdge(x, y + 1, 3); + } + } + } + if (outgoing.size === 0) { + return ''; + } + + // At a pinch vertex (two regions touching diagonally) two edges leave the + // same corner; preferring the tightest right turn keeps each loop around its + // own region instead of crossing over to the neighbour. + const takeEdge = (vertex: number, incoming: number): number | null => { + const bucket = outgoing.get(vertex); + if (!bucket || bucket.length === 0) { + return null; + } + for (const direction of [(incoming + 1) % 4, incoming, (incoming + 3) % 4]) { + const index = bucket.indexOf(direction); + if (index !== -1) { + bucket.splice(index, 1); + if (bucket.length === 0) { + outgoing.delete(vertex); + } + return direction; + } + } + return null; + }; + + const segments: string[] = []; + for (const [startVertex, bucket] of outgoing) { + while (bucket.length > 0) { + let direction = bucket[0]!; + bucket.splice(0, 1); + if (bucket.length === 0) { + outgoing.delete(startVertex); + } + let vertex = startVertex; + const startX = vertex % stride; + const startY = (vertex - startX) / stride; + const points: number[] = [startX, startY]; + let lastDirection = direction; + for (;;) { + vertex += DIRECTION_Y[direction]! * stride + DIRECTION_X[direction]!; + const x = vertex % stride; + const y = (vertex - x) / stride; + if (direction === lastDirection && points.length >= 4) { + points[points.length - 2] = x; + points[points.length - 1] = y; + } else { + points.push(x, y); + } + lastDirection = direction; + if (vertex === startVertex) { + break; + } + const next = takeEdge(vertex, direction); + if (next === null) { + // Unreachable for well-formed edge sets; bail rather than loop forever. + break; + } + direction = next; + } + // The walk re-appends the start vertex before closing; `Z` already closes. + if (points.length >= 4 && points[points.length - 2] === startX && points[points.length - 1] === startY) { + points.length -= 2; + } + let d = `M ${origin.x + points[0]!} ${origin.y + points[1]!}`; + for (let index = 2; index < points.length; index += 2) { + d += ` L ${origin.x + points[index]!} ${origin.y + points[index + 1]!}`; + } + segments.push(`${d} Z`); + } + } + return segments.join(' '); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionOps.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionOps.test.ts new file mode 100644 index 00000000000..59314ea8be1 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionOps.test.ts @@ -0,0 +1,145 @@ +import type { StubRasterBackend, StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { eraseMaskedRegion, fillMaskedRegion } from '@workbench/canvas-engine/selection/selectionOps'; +import { describe, expect, it } from 'vitest'; + +/** Wraps the stub backend so every scratch surface it mints is captured for assertions. */ +const createCapturingBackend = (): { backend: StubRasterBackend; created: StubRasterSurface[] } => { + const inner = createTestStubRasterBackend(); + const created: StubRasterSurface[] = []; + return { + backend: { + ...inner, + createSurface: (w, h) => { + const s = inner.createSurface(w, h); + created.push(s); + return s; + }, + }, + created, + }; +}; + +const compositeOps = (surface: StubRasterSurface): unknown[] => + surface.callLog.filter((e) => e.op === 'set' && e.args[0] === 'globalCompositeOperation').map((e) => e.args[1]); + +describe('fillMaskedRegion', () => { + it('masks the color to the selection (destination-in scratch) and draws it over the target', () => { + const { backend, created } = createCapturingBackend(); + const target = backend.createSurface(100, 100); + const mask = backend.createSurface(100, 100); + created.length = 0; // ignore target/mask; capture only the op's scratch + + fillMaskedRegion({ + backend, + color: '#ff0000', + mask, + maskOrigin: { x: 0, y: 0 }, + rect: { height: 20, width: 20, x: 10, y: 10 }, + target, + targetOrigin: { x: 0, y: 0 }, + }); + + // The scratch: fill the color, then intersect with the mask. + const scratch = created[0]!; + const scratchOps = scratch.callLog.map((e) => e.op); + expect(scratchOps).toContain('fillRect'); + expect(compositeOps(scratch)).toContain('destination-in'); + + // The target: the masked color is drawn source-over at the rect origin. + const targetDraw = target.callLog.find((e) => e.op === 'drawImage'); + expect(targetDraw).toBeDefined(); + expect(compositeOps(target)).toContain('source-over'); + }); + + it('uses source-atop on the target when composite is source-atop (transparency lock)', () => { + const { backend, created } = createCapturingBackend(); + const target = backend.createSurface(100, 100); + const mask = backend.createSurface(100, 100); + created.length = 0; + + fillMaskedRegion({ + backend, + color: '#ff0000', + composite: 'source-atop', + mask, + maskOrigin: { x: 0, y: 0 }, + rect: { height: 20, width: 20, x: 10, y: 10 }, + target, + targetOrigin: { x: 0, y: 0 }, + }); + + // Transparency lock: the final blit is source-atop, so colour lands ONLY where + // the target is already opaque (never fills transparent space). + expect(compositeOps(target)).toContain('source-atop'); + expect(compositeOps(target)).not.toContain('source-over'); + }); + + it('translates the draws by the target/mask origins (content-sized placement)', () => { + const { backend, created } = createCapturingBackend(); + const target = backend.createSurface(100, 100); + const mask = backend.createSurface(100, 100); + created.length = 0; + + // Edit region at doc (50,50); mask surface origin (40,40); target cache + // origin (30,30). The mask is drawn into the region-local scratch at + // (maskOrigin - rect) = (-10,-10); the scratch is drawn onto the target at + // (rect - targetOrigin) = (20,20). + fillMaskedRegion({ + backend, + color: '#ff0000', + mask, + maskOrigin: { x: 40, y: 40 }, + rect: { height: 20, width: 20, x: 50, y: 50 }, + target, + targetOrigin: { x: 30, y: 30 }, + }); + + const scratch = created[0]!; + const scratchMaskDraw = scratch.callLog.find((e) => e.op === 'drawImage'); + expect(scratchMaskDraw?.args.slice(1, 3)).toEqual([-10, -10]); + const targetDraw = target.callLog.find((e) => e.op === 'drawImage'); + expect(targetDraw?.args.slice(1, 3)).toEqual([20, 20]); + }); + + it('is a no-op for an empty rect', () => { + const { backend } = createCapturingBackend(); + const target = backend.createSurface(10, 10); + const mask = backend.createSurface(10, 10); + const before = target.callLog.length; + fillMaskedRegion({ + backend, + color: '#000', + mask, + maskOrigin: { x: 0, y: 0 }, + rect: { height: 0, width: 0, x: 0, y: 0 }, + target, + targetOrigin: { x: 0, y: 0 }, + }); + expect(target.callLog.length).toBe(before); + }); +}); + +describe('eraseMaskedRegion', () => { + it('composites destination-out through the mask onto the target', () => { + const { backend, created } = createCapturingBackend(); + const target = backend.createSurface(100, 100); + const mask = backend.createSurface(100, 100); + created.length = 0; + + eraseMaskedRegion({ + backend, + mask, + maskOrigin: { x: 0, y: 0 }, + rect: { height: 20, width: 20, x: 5, y: 5 }, + target, + targetOrigin: { x: 0, y: 0 }, + }); + + const scratch = created[0]!; + expect(scratch.callLog.map((e) => e.op)).toContain('drawImage'); + expect(compositeOps(target)).toContain('destination-out'); + expect(target.callLog.some((e) => e.op === 'drawImage')).toBe(true); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionOps.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionOps.ts new file mode 100644 index 00000000000..73387d07680 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionOps.ts @@ -0,0 +1,114 @@ +/** + * Selection-constrained raster primitives: fill and erase a layer's pixels + * within the selection mask. + * + * These are the pure pixel ops behind the engine's `fillSelection` / + * `eraseSelection` (the engine owns the layer-eligibility guards, dirty-rect + * before/after capture, undo history, and persistence around them). Each op + * composites through a scratch surface so the write lands ONLY where the mask is + * opaque — pixels outside the selection are never touched — and flows entirely + * through the {@link RasterBackend} `ctx` seam, so it runs on the node stub. + * + * Coordinate spaces: both the layer cache and the selection mask are content- + * sized (placed) surfaces with their own origins. `rect` is the edit region in + * document (= layer-local) space; `targetOrigin` / `maskOrigin` are the target + * cache surface's and the mask surface's document-space origins, so a document + * coordinate `d` maps to target-surface `d - targetOrigin` and mask-surface + * `d - maskOrigin`. + * + * Zero React, zero import-time side effects. + */ + +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; + +/** Shared inputs for a masked layer edit over a document-space `rect`. */ +export interface MaskedEditParams { + backend: RasterBackend; + /** The layer cache surface (content-sized) and its document-space origin. */ + target: RasterSurface; + /** The target surface's document-space origin (its cache rect's `x`/`y`). */ + targetOrigin: Vec2; + /** The selection mask surface (alpha 255 inside). */ + mask: RasterSurface; + /** The mask surface's document-space origin (its selection rect's `x`/`y`). */ + maskOrigin: Vec2; + /** The dirty region to edit (document space), integer bounds. */ + rect: Rect; +} + +/** + * Fills `color` into `target` wherever `mask` is opaque, within `rect`. Builds a + * `color`-filled scratch (region-local), intersects it with the mask + * (`destination-in`), then draws the masked color over the target. + * + * `composite` is the final blit's op: `source-over` (default) fills every masked + * pixel; `source-atop` honours a transparency lock — colour lands ONLY where the + * target is already opaque, never into transparent space (mirrors the + * transparency-locked brush in `paintTool.ts`). + */ +export const fillMaskedRegion = ({ + backend, + color, + composite = 'source-over', + mask, + maskOrigin, + rect, + target, + targetOrigin, +}: MaskedEditParams & { color: string; composite?: 'source-over' | 'source-atop' }): void => { + if (rect.width <= 0 || rect.height <= 0) { + return; + } + const scratch = backend.createSurface(rect.width, rect.height); + const sctx = scratch.ctx; + sctx.setTransform(1, 0, 0, 1, 0, 0); + sctx.globalCompositeOperation = 'source-over'; + sctx.globalAlpha = 1; + sctx.fillStyle = color; + sctx.fillRect(0, 0, rect.width, rect.height); + // Keep the color only where the mask is opaque (mask drawn at its offset within + // the region-local scratch). + sctx.globalCompositeOperation = 'destination-in'; + sctx.drawImage(mask.canvas, maskOrigin.x - rect.x, maskOrigin.y - rect.y); + + const ctx = target.ctx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalCompositeOperation = composite; + ctx.globalAlpha = 1; + ctx.drawImage(scratch.canvas, rect.x - targetOrigin.x, rect.y - targetOrigin.y); + ctx.restore(); +}; + +/** + * Erases (`destination-out`) `target`'s pixels wherever `mask` is opaque, within + * `rect`. The mask shape is copied into a region-local scratch and composited out + * of the target. + */ +export const eraseMaskedRegion = ({ + backend, + mask, + maskOrigin, + rect, + target, + targetOrigin, +}: MaskedEditParams): void => { + if (rect.width <= 0 || rect.height <= 0) { + return; + } + const scratch = backend.createSurface(rect.width, rect.height); + const sctx = scratch.ctx; + sctx.setTransform(1, 0, 0, 1, 0, 0); + sctx.globalCompositeOperation = 'source-over'; + sctx.globalAlpha = 1; + sctx.drawImage(mask.canvas, maskOrigin.x - rect.x, maskOrigin.y - rect.y); + + const ctx = target.ctx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalCompositeOperation = 'destination-out'; + ctx.globalAlpha = 1; + ctx.drawImage(scratch.canvas, rect.x - targetOrigin.x, rect.y - targetOrigin.y); + ctx.restore(); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionState.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionState.test.ts new file mode 100644 index 00000000000..afd648fdb63 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionState.test.ts @@ -0,0 +1,303 @@ +import type { StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { PlacedSurface, Rect } from '@workbench/canvas-engine/types'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { createSelectionState, type SelectionState } from '@workbench/canvas-engine/selection/selectionState'; +import { describe, expect, it, vi } from 'vitest'; + +const DOC = { height: 100, width: 100 }; + +const rectBounds = (x: number, y: number, w: number, h: number): Rect => ({ height: h, width: w, x, y }); + +/** A fake Path2D carrier (Path2D is absent in node); the stub ctx just records it. */ +const fakePath = (id: string): Path2D => ({ id }) as unknown as Path2D; + +const imageData = (alphas: readonly number[]): ImageData => { + const data = new Uint8ClampedArray(alphas.length * 4); + alphas.forEach((alpha, index) => { + data[index * 4] = index + 1; + data[index * 4 + 1] = index + 11; + data[index * 4 + 2] = index + 21; + data[index * 4 + 3] = alpha; + }); + return { colorSpace: 'srgb', data, height: 2, width: 2 } as ImageData; +}; + +const placedMask = (alphas: readonly number[]): { placed: PlacedSurface; pixels: ImageData } => { + const backend = createTestStubRasterBackend(); + const surface = backend.createSurface(2, 2); + const pixels = imageData(alphas); + Object.defineProperty(surface.ctx, 'getImageData', { value: () => pixels }); + return { pixels, placed: { rect: rectBounds(7, -3, 2, 2), surface } }; +}; + +const createHarness = (docSize: { width: number; height: number } | null = DOC) => { + const backend = createTestStubRasterBackend(); + const onChange = vi.fn(); + const selection: SelectionState = createSelectionState({ + backend, + createPath2D: (d) => ({ d }) as unknown as Path2D, + getDocumentSize: () => docSize, + onChange, + }); + return { backend, onChange, selection }; +}; + +/** The recorded op names on the mask surface. */ +const maskLog = (selection: SelectionState): { op: string; args: unknown[] }[] => + (selection.mask()?.surface as StubRasterSurface | undefined)?.callLog ?? []; + +/** The last `globalCompositeOperation` value set before a given op, scanning the whole log. */ +const compositeOpsFor = (log: { op: string; args: unknown[] }[]): unknown[] => + log.filter((e) => e.op === 'set' && e.args[0] === 'globalCompositeOperation').map((e) => e.args[1]); + +describe('selectionState: mask building from a path', () => { + it('replace clears then source-over fills the path, sets bounds + hasSelection', () => { + const { selection, onChange } = createHarness(); + selection.commit({ bounds: rectBounds(10, 10, 20, 20), op: 'replace', path: fakePath('p1') }); + + expect(selection.hasSelection()).toBe(true); + expect(selection.bounds()).toEqual(rectBounds(10, 10, 20, 20)); + expect(selection.antsPaths()).toHaveLength(1); + expect(onChange).toHaveBeenCalledTimes(1); + + const log = maskLog(selection); + const ops = log.map((e) => e.op); + expect(ops).toContain('clearRect'); + expect(ops).toContain('fill'); + expect(compositeOpsFor(log)).toContain('source-over'); + }); + + it('no longer clamps bounds to the document rect (infinite plane); the mask is bounded to the path', () => { + const { selection } = createHarness(); + selection.commit({ bounds: rectBounds(-10, -10, 200, 200), op: 'replace', path: fakePath('p') }); + // Content-sized: the selection bounds are the path's own bounds, not clamped + // to the document. The mask surface is placed at that (negative) origin. + expect(selection.bounds()).toEqual(rectBounds(-10, -10, 200, 200)); + expect(selection.mask()?.rect).toEqual(rectBounds(-10, -10, 200, 200)); + }); +}); + +describe('selectionState: boolean ops', () => { + it('add unions bounds with source-over and keeps both ant paths', () => { + const { selection } = createHarness(); + selection.commit({ bounds: rectBounds(10, 10, 10, 10), op: 'replace', path: fakePath('a') }); + selection.commit({ bounds: rectBounds(40, 40, 10, 10), op: 'add', path: fakePath('b') }); + + expect(selection.hasSelection()).toBe(true); + expect(selection.bounds()).toEqual(rectBounds(10, 10, 40, 40)); + expect(selection.antsPaths()).toHaveLength(2); + expect(compositeOpsFor(maskLog(selection))).toContain('source-over'); + }); + + it('subtract composites destination-out', () => { + const { selection } = createHarness(); + selection.commit({ bounds: rectBounds(0, 0, 50, 50), op: 'replace', path: fakePath('a') }); + selection.commit({ bounds: rectBounds(10, 10, 10, 10), op: 'subtract', path: fakePath('b') }); + expect(selection.hasSelection()).toBe(true); + expect(compositeOpsFor(maskLog(selection))).toContain('destination-out'); + }); + + it('intersect composites destination-in and intersects the bounds', () => { + const { selection } = createHarness(); + selection.commit({ bounds: rectBounds(0, 0, 50, 50), op: 'replace', path: fakePath('a') }); + selection.commit({ bounds: rectBounds(30, 30, 50, 50), op: 'intersect', path: fakePath('b') }); + expect(selection.hasSelection()).toBe(true); + expect(selection.bounds()).toEqual(rectBounds(30, 30, 20, 20)); + expect(compositeOpsFor(maskLog(selection))).toContain('destination-in'); + }); + + it('intersect against no existing selection clears to nothing', () => { + const { selection } = createHarness(); + selection.commit({ bounds: rectBounds(0, 0, 10, 10), op: 'intersect', path: fakePath('a') }); + expect(selection.hasSelection()).toBe(false); + expect(selection.bounds()).toBeNull(); + expect(selection.mask()).toBeNull(); + }); +}); + +describe('selectionState: selectAll / invert / clear', () => { + it('selectAll fills the whole document rect', () => { + const { selection } = createHarness(); + selection.selectAll(rectBounds(0, 0, 100, 100)); + expect(selection.hasSelection()).toBe(true); + expect(selection.bounds()).toEqual(rectBounds(0, 0, 100, 100)); + expect(maskLog(selection).map((e) => e.op)).toContain('fillRect'); + }); + + it('invert of an empty selection selects everything', () => { + const { selection } = createHarness(); + selection.invert(rectBounds(0, 0, 100, 100)); + expect(selection.hasSelection()).toBe(true); + expect(selection.bounds()).toEqual(rectBounds(0, 0, 100, 100)); + }); + + it('invert of a selection keeps a document-border ants path plus the former outline', () => { + const { selection } = createHarness(); + selection.commit({ bounds: rectBounds(10, 10, 20, 20), op: 'replace', path: fakePath('a') }); + expect(selection.antsPaths()).toHaveLength(1); + selection.invert(rectBounds(0, 0, 100, 100)); + expect(selection.hasSelection()).toBe(true); + // The document border plus the former outline. + expect(selection.antsPaths()).toHaveLength(2); + }); + + it('clear deselects and drops the mask + bounds', () => { + const { selection, onChange } = createHarness(); + selection.commit({ bounds: rectBounds(10, 10, 20, 20), op: 'replace', path: fakePath('a') }); + onChange.mockClear(); + selection.clear(); + expect(selection.hasSelection()).toBe(false); + expect(selection.bounds()).toBeNull(); + expect(selection.mask()).toBeNull(); + expect(selection.antsPaths()).toHaveLength(0); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); + +describe('selectionState: replaceMask', () => { + it('replaces prior pixels with an isolated, pixel-exact alpha mask at its world rect', () => { + const { selection, onChange } = createHarness(); + selection.selectAll(rectBounds(0, 0, 50, 50)); + const previousSurface = selection.mask()!.surface; + const source = placedMask([0, 64, 255, 0]); + onChange.mockClear(); + + selection.replaceMask(source.placed); + + expect(selection.hasSelection()).toBe(true); + expect(selection.bounds()).toEqual(source.placed.rect); + expect(selection.mask()?.rect).toEqual(source.placed.rect); + expect(selection.mask()?.surface).not.toBe(source.placed.surface); + expect(selection.mask()?.surface).not.toBe(previousSurface); + // The ants trace the mask's true edge — here only the solid (0,1) pixel — + // rather than the replacement's bounding rect. + expect(selection.antsPaths()).toEqual([{ d: 'M 7 -2 L 8 -2 L 8 -1 L 7 -1 Z' } as unknown as Path2D]); + + const put = maskLog(selection).find((entry) => entry.op === 'putImageData'); + const copied = put?.args[0] as ImageData | undefined; + expect(copied).toBeDefined(); + expect(copied).not.toBe(source.pixels); + expect([...copied!.data]).toEqual([...source.pixels.data]); + + source.pixels.data.fill(0); + expect(copied!.data.filter((_, index) => index % 4 === 3)).toEqual(new Uint8ClampedArray([0, 64, 255, 0])); + expect(onChange).toHaveBeenCalledOnce(); + }); + + it('clears the selection when the replacement has no alpha', () => { + const { selection, onChange } = createHarness(); + selection.selectAll(rectBounds(0, 0, 50, 50)); + onChange.mockClear(); + + selection.replaceMask(placedMask([0, 0, 0, 0]).placed); + + expect(selection.hasSelection()).toBe(false); + expect(selection.bounds()).toBeNull(); + expect(selection.mask()).toBeNull(); + expect(selection.antsPaths()).toEqual([]); + expect(onChange).toHaveBeenCalledOnce(); + }); + + it('detaches an empty replacement without touching the prior mask surface', () => { + const { selection, onChange } = createHarness(); + selection.selectAll(rectBounds(1, 2, 30, 40)); + const before = selection.mask()!; + const beforePath = selection.antsPaths()[0]; + const previousLogLength = (before.surface as StubRasterSurface).callLog.length; + const clearRect = vi.fn(() => { + throw new Error('prior selection surface must not be cleared'); + }); + Object.defineProperty(before.surface.ctx, 'clearRect', { value: clearRect }); + onChange.mockClear(); + + expect(() => selection.replaceMask(placedMask([0, 0, 0, 0]).placed)).not.toThrow(); + + expect(clearRect).not.toHaveBeenCalled(); + expect((before.surface as StubRasterSurface).callLog).toHaveLength(previousLogLength); + expect(before.rect).toEqual(rectBounds(1, 2, 30, 40)); + expect(beforePath).toBeDefined(); + expect(selection.hasSelection()).toBe(false); + expect(selection.bounds()).toBeNull(); + expect(selection.mask()).toBeNull(); + expect(selection.antsPaths()).toEqual([]); + expect(onChange).toHaveBeenCalledOnce(); + }); + + it('preserves the exact prior selection when staging replacement pixels fails', () => { + const { backend, selection, onChange } = createHarness(); + selection.selectAll(rectBounds(1, 2, 30, 40)); + const before = selection.mask()!; + const beforePaths = selection.antsPaths(); + const createSurface = backend.createSurface; + vi.spyOn(backend, 'createSurface').mockImplementation((width, height) => { + const surface = createSurface(width, height); + Object.defineProperty(surface.ctx, 'putImageData', { + value: () => { + throw new Error('selection pixel staging failed'); + }, + }); + return surface; + }); + onChange.mockClear(); + + expect(() => selection.replaceMask(placedMask([0, 64, 255, 0]).placed)).toThrow('selection pixel staging failed'); + + expect(selection.mask()?.surface).toBe(before.surface); + expect(selection.mask()?.rect).toEqual(before.rect); + expect(selection.bounds()).toEqual(before.rect); + expect(selection.antsPaths()).toHaveLength(beforePaths.length); + expect(selection.antsPaths()[0]).toBe(beforePaths[0]); + expect(selection.hasSelection()).toBe(true); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('preserves the exact prior selection when staging its ants path fails', () => { + const backend = createTestStubRasterBackend(); + const onChange = vi.fn(); + let failPath = false; + const selection = createSelectionState({ + backend, + createPath2D: (d) => { + if (failPath) { + throw new Error('selection path staging failed'); + } + return { d } as unknown as Path2D; + }, + getDocumentSize: () => DOC, + onChange, + }); + selection.selectAll(rectBounds(1, 2, 30, 40)); + const before = selection.mask()!; + const beforePaths = selection.antsPaths(); + failPath = true; + onChange.mockClear(); + + expect(() => selection.replaceMask(placedMask([0, 64, 255, 0]).placed)).toThrow('selection path staging failed'); + + expect(selection.mask()?.surface).toBe(before.surface); + expect(selection.mask()?.rect).toEqual(before.rect); + expect(selection.bounds()).toEqual(before.rect); + expect(selection.antsPaths()).toHaveLength(beforePaths.length); + expect(selection.antsPaths()[0]).toBe(beforePaths[0]); + expect(selection.hasSelection()).toBe(true); + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +describe('selectionState: teardown + guards', () => { + it('commit is a no-op with no document size', () => { + const { selection, onChange } = createHarness(null); + selection.commit({ bounds: rectBounds(0, 0, 10, 10), op: 'replace', path: fakePath('a') }); + expect(selection.hasSelection()).toBe(false); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('dispose clears state (document-replace teardown path)', () => { + const { selection } = createHarness(); + selection.commit({ bounds: rectBounds(0, 0, 10, 10), op: 'replace', path: fakePath('a') }); + selection.dispose(); + expect(selection.hasSelection()).toBe(false); + expect(selection.mask()).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionState.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionState.ts new file mode 100644 index 00000000000..9862a9298e9 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionState.ts @@ -0,0 +1,410 @@ +/** + * Engine-transient pixel selection. + * + * Per the plan's state-tier table, a selection is *interaction* state: it lives + * on the engine, never in the reducer contract, and is not undoable. The source + * of truth is the set of closed `Path2D` polygons the lasso tool commits; the + * derived artifact is a bounded **mask surface** (alpha 255 inside the + * selection), sized to the selection extent and placed in document space (a + * {@link PlacedSurface}), built through the {@link RasterBackend} seam. Boolean ops + * (`replace`/`add`/`subtract`/`intersect`) are applied to the MASK — that is the + * single source of truth for painting/fill clipping. The committed path list is + * kept only for {@link marchingAnts} rendering (stroking the outlines), not for + * re-deriving the mask. + * + * ## Marching-ants approximation + * + * Ants are stroked from the committed path LIST (each path drawn dashed), not by + * tracing the mask's true edge. This matches the mask exactly for `replace` and + * `add`, and reads correctly for `subtract` (the cut-out path's outline shows as + * a hole) and `selectAll`/`invert` (a document-border path is included). It is an + * approximation for `intersect` and for heavily overlapping compositions, where + * the true selection outline is the boolean result rather than the union of the + * source outlines — the ants may show interior source edges that the mask does + * not. The exception is {@link SelectionState.replaceMask} (pixel-mask + * replacement, e.g. a Select Object result), whose ants ARE traced from the + * mask's true edge via {@link traceMaskOutlinePath}. The mask itself is always + * exact. + * + * ## Emptiness + * + * `hasSelection` is tracked structurally, not by scanning pixels (a scan is + * unavailable on the node raster stub and costly on the DOM). Consequently, + * `subtract`ing away the entire selection leaves `hasSelection` true until an + * explicit deselect — a known, documented limitation of this phase. + * + * Zero React, zero import-time side effects. + */ + +import type { CreatePath2D } from '@workbench/canvas-engine/freehand'; +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { PlacedSurface, Rect, SelectionOp, Vec2 } from '@workbench/canvas-engine/types'; + +import { intersect, isEmpty, roundOut, union } from '@workbench/canvas-engine/math/rect'; +import { traceMaskOutlinePath } from '@workbench/canvas-engine/selection/maskOutline'; + +/** A committed selection contribution: the closed path and the op it applied. */ +export interface SelectionCommit { + path: Path2D; + /** The path's document-space bounds (used to maintain the selection bounds cheaply). */ + bounds: Rect; + op: SelectionOp; +} + +/** The engine-facing selection handle. */ +export interface SelectionState { + /** Whether a selection currently exists (drives clip/fill/ants gating). */ + hasSelection(): boolean; + /** The selection's document-space bounds, or `null` when empty. */ + bounds(): Rect | null; + /** + * The selection mask as a placed surface (alpha 255 inside) in document space, + * bounded to the selection extent (the `rect` records its origin/size), or + * `null` when empty. + */ + mask(): PlacedSurface | null; + /** The committed path outlines, for marching-ants rendering. */ + antsPaths(): readonly Path2D[]; + /** True if `p` (document space) is inside the selection. Cheap 1×1 read; `false` when empty. */ + containsPoint(p: Vec2): boolean; + /** Applies a committed lasso path against the mask with the given boolean op. */ + commit(commit: SelectionCommit): void; + /** Replaces the selection with an alpha-bearing surface placed in document space. */ + replaceMask(mask: PlacedSurface): void; + /** Selects the whole `domain` rectangle (the engine passes `content ∪ bbox`). */ + selectAll(domain: Rect): void; + /** + * Inverts the selection within `domain` (empty → select `domain`). The domain + * is the bounded region the complement is taken over — the engine passes + * `content ∪ bbox`, the coherent analogue of legacy's bounded canvas now that + * the document rect is retired. + */ + invert(domain: Rect): void; + /** Clears the selection (deselect). */ + clear(): void; + /** Releases the mask surface reference. */ + dispose(): void; +} + +/** Dependencies injected by the engine. */ +export interface SelectionStateDeps { + backend: RasterBackend; + createPath2D: CreatePath2D; + /** Current document pixel size (the mask surface size), or `null` when no document. */ + getDocumentSize(): { width: number; height: number } | null; + /** Called after every mutation (engine syncs the `hasSelection` store + overlay/ants). */ + onChange(): void; +} + +const MASK_FILL = '#ffffff'; + +/** SVG path data for a closed rectangle in document space. */ +const rectToPathData = (r: Rect): string => + `M ${r.x} ${r.y} L ${r.x + r.width} ${r.y} L ${r.x + r.width} ${r.y + r.height} L ${r.x} ${r.y + r.height} Z`; + +/** Builds a closed rectangle `Path2D` (document space) via the injected factory. */ +const rectPath = (createPath2D: CreatePath2D, r: Rect): Path2D => createPath2D(rectToPathData(r)); + +/** The canvas composite op that realizes each boolean selection op on the mask. */ +const compositeForOp = (op: SelectionOp): GlobalCompositeOperation => { + switch (op) { + case 'add': + case 'replace': + return 'source-over'; + case 'subtract': + return 'destination-out'; + case 'intersect': + return 'destination-in'; + } +}; + +/** Creates an engine-transient selection bound to the raster backend seam. */ +export const createSelectionState = (deps: SelectionStateDeps): SelectionState => { + const { backend, createPath2D, getDocumentSize, onChange } = deps; + + let mask: RasterSurface | null = null; + // The mask surface's document-space bounds (its origin/extent). The mask is + // bounded to the selection, not the document — surface pixel (sx,sy) maps to + // document (maskRect.x+sx, maskRect.y+sy). + let maskRect: Rect | null = null; + let commits: SelectionCommit[] = []; + let selectionBounds: Rect | null = null; + let selected = false; + + /** + * Ensures the mask surface exists and covers `rect` (integer bounds), + * preserving any existing mask pixels at their new offset when it must grow. + */ + const ensureMask = (rect: Rect): RasterSurface => { + const want: Rect = roundOut(rect); + if (!mask || !maskRect) { + mask = backend.createSurface(Math.max(0, want.width), Math.max(0, want.height)); + maskRect = want; + return mask; + } + const grown = union(maskRect, want); + if ( + grown.x === maskRect.x && + grown.y === maskRect.y && + grown.width === maskRect.width && + grown.height === maskRect.height + ) { + return mask; + } + // Grow, preserving old pixels at their new offset (snapshot before resize). + let snapshot: ImageData | null = null; + if (maskRect.width > 0 && maskRect.height > 0) { + snapshot = mask.ctx.getImageData(0, 0, maskRect.width, maskRect.height); + } + mask.resize(grown.width, grown.height); + if (snapshot) { + mask.ctx.putImageData(snapshot, maskRect.x - grown.x, maskRect.y - grown.y); + } + maskRect = grown; + return mask; + }; + + /** Resets the mask surface to exactly `rect` (cleared), for `replace`-style ops. */ + const resetMask = (rect: Rect): RasterSurface => { + const want: Rect = roundOut(rect); + if (!mask) { + mask = backend.createSurface(Math.max(0, want.width), Math.max(0, want.height)); + } else if (mask.width !== want.width || mask.height !== want.height) { + mask.resize(Math.max(0, want.width), Math.max(0, want.height)); + } + maskRect = want; + clearSurface(mask); + return mask; + }; + + /** + * Fills a path into the mask under `op`'s composite mode. The mask surface is + * offset by `maskRect.origin`, so the (document-space) path is drawn through a + * translate that maps document → surface coordinates. + */ + const fillPath = (surface: RasterSurface, origin: Vec2, path: Path2D, op: GlobalCompositeOperation): void => { + const ctx = surface.ctx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, -origin.x, -origin.y); + ctx.globalCompositeOperation = op; + ctx.globalAlpha = 1; + ctx.fillStyle = MASK_FILL; + ctx.fill(path); + ctx.restore(); + }; + + const clearSurface = (surface: RasterSurface): void => { + const ctx = surface.ctx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalCompositeOperation = 'source-over'; + ctx.clearRect(0, 0, surface.width, surface.height); + ctx.restore(); + }; + + const clear = (): void => { + commits = []; + selectionBounds = null; + selected = false; + if (mask) { + clearSurface(mask); + } + onChange(); + }; + + const replaceMask = (next: PlacedSurface): void => { + const rect = roundOut(next.rect); + const publishEmptyReplacement = (): void => { + // Replacing from a valid empty alpha result should not clear the old + // surface in place: clearSurface() is fallible and would run after the + // logical selection flags had already changed. Detach the old surface and + // atomically publish an empty selection instead; it is reclaimed by GC. + mask = null; + maskRect = null; + commits = []; + selectionBounds = null; + selected = false; + onChange(); + }; + if (isEmpty(rect) || next.surface.width <= 0 || next.surface.height <= 0) { + publishEmptyReplacement(); + return; + } + + const source = next.surface.ctx.getImageData(0, 0, next.surface.width, next.surface.height); + let hasAlpha = false; + for (let index = 3; index < source.data.length; index += 4) { + if (source.data[index] !== 0) { + hasAlpha = true; + break; + } + } + if (!hasAlpha) { + publishEmptyReplacement(); + return; + } + + const copiedData = new Uint8ClampedArray(source.data); + const copied = + typeof ImageData === 'undefined' + ? ({ colorSpace: source.colorSpace, data: copiedData, height: source.height, width: source.width } as ImageData) + : new ImageData(copiedData, source.width, source.height); + // Prepare every fallible replacement artifact before publishing any state. + // If allocation, pixel upload, or Path2D construction fails, the exact prior + // selection remains authoritative and the engine may safely report failure. + const nextMask = backend.createSurface(rect.width, rect.height); + nextMask.ctx.putImageData(copied, 0, 0); + // Trace the mask's true edge for the ants; a mask whose alpha never reaches + // the solid threshold still selected something (hasAlpha above), so fall + // back to tracing any non-zero coverage rather than showing no outline. + const alphaSource = { data: copiedData, height: source.height, width: source.width }; + const outline = + traceMaskOutlinePath(alphaSource, rect) || traceMaskOutlinePath(alphaSource, rect, 1) || rectToPathData(rect); + const nextPath = createPath2D(outline); + + mask = nextMask; + maskRect = rect; + commits = [{ bounds: rect, op: 'replace', path: nextPath }]; + selectionBounds = rect; + selected = true; + onChange(); + }; + + const commit = (next: SelectionCommit): void => { + if (!getDocumentSize()) { + // No active document ⇒ no selection surface to build. + return; + } + const bounds = isEmpty(next.bounds) ? null : roundOut(next.bounds); + + if (next.op === 'replace') { + if (!bounds) { + clear(); + return; + } + const surface = resetMask(bounds); + fillPath(surface, { x: maskRect!.x, y: maskRect!.y }, next.path, 'source-over'); + commits = [next]; + selectionBounds = bounds; + selected = true; + onChange(); + return; + } + + if (next.op === 'intersect' && (!selected || !selectionBounds)) { + // Intersecting with nothing yields nothing. + clear(); + return; + } + + // For `add` the mask may need to grow to include the new path; `subtract` / + // `intersect` never grow the covered region (they only remove). + const surface = + next.op === 'add' && bounds + ? ensureMask(bounds) + : ensureMask(selectionBounds ?? bounds ?? { height: 0, width: 0, x: 0, y: 0 }); + fillPath(surface, { x: maskRect!.x, y: maskRect!.y }, next.path, compositeForOp(next.op)); + commits.push(next); + + switch (next.op) { + case 'add': + selectionBounds = selectionBounds && bounds ? union(selectionBounds, bounds) : (bounds ?? selectionBounds); + selected = selected || bounds !== null; + break; + case 'subtract': + // Bounds can only shrink; without a pixel scan we keep the (over-approx) + // prior bounds. `selected` stays true — see module docs. + break; + case 'intersect': + selectionBounds = selectionBounds && bounds ? intersect(selectionBounds, bounds) : null; + selected = selectionBounds !== null; + break; + } + onChange(); + }; + + const selectAll = (domain: Rect): void => { + const rect = roundOut(domain); + const surface = resetMask(rect); + const ctx = surface.ctx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalCompositeOperation = 'source-over'; + ctx.globalAlpha = 1; + ctx.fillStyle = MASK_FILL; + ctx.fillRect(0, 0, surface.width, surface.height); + ctx.restore(); + commits = [{ bounds: rect, op: 'replace', path: rectPath(createPath2D, rect) }]; + selectionBounds = rect; + selected = !isEmpty(rect); + onChange(); + }; + + const invert = (domain: Rect): void => { + if (!selected || !mask || !maskRect) { + // Inverting an empty selection selects the whole domain. + selectAll(domain); + return; + } + const rect = roundOut(domain); + // The current mask, captured over the domain: draw the (offset) mask onto a + // domain-sized temp so the complement is taken in domain-local coordinates. + const temp = backend.createSurface(Math.max(0, rect.width), Math.max(0, rect.height)); + const tctx = temp.ctx; + tctx.setTransform(1, 0, 0, 1, 0, 0); + tctx.globalCompositeOperation = 'source-over'; + tctx.globalAlpha = 1; + tctx.fillStyle = MASK_FILL; + tctx.fillRect(0, 0, temp.width, temp.height); + tctx.globalCompositeOperation = 'destination-out'; + // Punch out the current mask at its offset within the domain. + tctx.drawImage(mask.canvas, maskRect.x - rect.x, maskRect.y - rect.y); + + const surface = resetMask(rect); + const ctx = surface.ctx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalCompositeOperation = 'source-over'; + ctx.drawImage(temp.canvas, 0, 0); + ctx.restore(); + + // Ants: the outer domain border plus the former outlines (now holes). + commits = [{ bounds: rect, op: 'replace', path: rectPath(createPath2D, rect) }, ...commits]; + selectionBounds = rect; + selected = true; + onChange(); + }; + + const containsPoint = (p: Vec2): boolean => { + if (!selected || !mask || !maskRect) { + return false; + } + const x = Math.floor(p.x) - maskRect.x; + const y = Math.floor(p.y) - maskRect.y; + if (x < 0 || y < 0 || x >= mask.width || y >= mask.height) { + return false; + } + const pixel = mask.ctx.getImageData(x, y, 1, 1); + return pixel.data[3] !== undefined && pixel.data[3] > 0; + }; + + return { + antsPaths: () => commits.map((entry) => entry.path), + bounds: () => selectionBounds, + clear, + commit, + containsPoint, + dispose: () => { + mask = null; + maskRect = null; + commits = []; + selectionBounds = null; + selected = false; + }, + hasSelection: () => selected, + invert, + mask: () => (selected && mask && maskRect ? { rect: maskRect, surface: mask } : null), + replaceMask, + selectAll, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxHitTest.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxHitTest.test.ts new file mode 100644 index 00000000000..449bcc7ac63 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxHitTest.test.ts @@ -0,0 +1,178 @@ +import type { Rect } from '@workbench/canvas-engine/types'; + +import { describe, expect, it } from 'vitest'; + +import { + BBOX_HANDLE_HIT_PX, + bboxEquals, + bboxHandleAt, + bboxHandlePoint, + bboxTargetAt, + constrainBboxToRatio, + isInsideRect, + moveBbox, + resizeBbox, + roundBbox, +} from './bboxHitTest'; + +const rect = (x: number, y: number, width: number, height: number): Rect => ({ height, width, x, y }); + +describe('bboxHandlePoint', () => { + it('places the eight handles on corners and edge midpoints', () => { + const r = rect(0, 0, 100, 80); + expect(bboxHandlePoint(r, 'nw')).toEqual({ x: 0, y: 0 }); + expect(bboxHandlePoint(r, 'ne')).toEqual({ x: 100, y: 0 }); + expect(bboxHandlePoint(r, 'se')).toEqual({ x: 100, y: 80 }); + expect(bboxHandlePoint(r, 'sw')).toEqual({ x: 0, y: 80 }); + expect(bboxHandlePoint(r, 'n')).toEqual({ x: 50, y: 0 }); + expect(bboxHandlePoint(r, 'e')).toEqual({ x: 100, y: 40 }); + expect(bboxHandlePoint(r, 's')).toEqual({ x: 50, y: 80 }); + expect(bboxHandlePoint(r, 'w')).toEqual({ x: 0, y: 40 }); + }); +}); + +describe('bboxHandleAt (screen-space hit-testing)', () => { + it('returns the handle whose grab area contains the point', () => { + const screenRect = rect(0, 0, 100, 100); + expect(bboxHandleAt(screenRect, { x: 0, y: 0 })).toBe('nw'); + expect(bboxHandleAt(screenRect, { x: 100, y: 100 })).toBe('se'); + expect(bboxHandleAt(screenRect, { x: 50, y: 0 })).toBe('n'); + }); + + it('tolerates a few pixels of slack around the handle', () => { + const screenRect = rect(0, 0, 100, 100); + const slack = BBOX_HANDLE_HIT_PX / 2 - 1; + expect(bboxHandleAt(screenRect, { x: slack, y: slack })).toBe('nw'); + // Just outside the grab area → no handle. + expect(bboxHandleAt(screenRect, { x: BBOX_HANDLE_HIT_PX, y: BBOX_HANDLE_HIT_PX })).toBeNull(); + }); + + it('keeps the grab area a constant screen size across zooms', () => { + // Same doc bbox {0,0,100,100} projected at two zooms. A point 5px from the + // top-left corner in SCREEN space hits 'nw' regardless of the frame's on-screen size. + const atZoom1 = rect(0, 0, 100, 100); + const atZoom4 = rect(0, 0, 400, 400); + expect(bboxHandleAt(atZoom1, { x: 5, y: 5 })).toBe('nw'); + expect(bboxHandleAt(atZoom4, { x: 5, y: 5 })).toBe('nw'); + // The midpoint of the top edge moves with zoom: 50 at zoom1, 200 at zoom4. + expect(bboxHandleAt(atZoom1, { x: 50, y: 0 })).toBe('n'); + // At zoom4 the top-edge midpoint lives at 200, so 50 grabs nothing. + expect(bboxHandleAt(atZoom4, { x: 50, y: 0 })).toBeNull(); + expect(bboxHandleAt(atZoom4, { x: 200, y: 0 })).toBe('n'); + }); + + it('prefers a corner over an overlapping edge midpoint on a tiny frame', () => { + // At a 10px frame the edge midpoints sit within the corner grab areas; corners win. + const tiny = rect(0, 0, 10, 10); + expect(bboxHandleAt(tiny, { x: 0, y: 5 })).toBe('nw'); + }); +}); + +describe('bboxTargetAt', () => { + const screenRect = rect(10, 10, 100, 100); + it('returns a handle when over one', () => { + expect(bboxTargetAt(screenRect, { x: 10, y: 10 })).toBe('nw'); + }); + it("returns 'move' inside the frame away from handles", () => { + expect(bboxTargetAt(screenRect, { x: 60, y: 60 })).toBe('move'); + }); + it('returns null outside the frame (bbox is never deselected)', () => { + expect(bboxTargetAt(screenRect, { x: 200, y: 200 })).toBeNull(); + }); +}); + +describe('isInsideRect', () => { + it('includes the border', () => { + expect(isInsideRect(rect(0, 0, 10, 10), { x: 0, y: 10 })).toBe(true); + expect(isInsideRect(rect(0, 0, 10, 10), { x: 11, y: 5 })).toBe(false); + }); +}); + +describe('resizeBbox — free (unconstrained)', () => { + const start = rect(0, 0, 100, 100); + + it('grows the SE corner and snaps the moved edges to the grid', () => { + const out = resizeBbox({ constrain: false, dx: 10, dy: 10, grid: 8, handle: 'se', ratio: 1, snap: true, start }); + // right = snap(110) = 112, bottom = snap(110) = 112, anchored at (0,0). + expect(out).toEqual(rect(0, 0, 112, 112)); + }); + + it('drags the NW corner (moves origin) and snaps', () => { + const out = resizeBbox({ constrain: false, dx: -10, dy: -10, grid: 8, handle: 'nw', ratio: 1, snap: true, start }); + // left = snap(-10) = -8, top = -8; right/bottom fixed at 100. + expect(out).toEqual(rect(-8, -8, 108, 108)); + }); + + it('resizes a single edge, leaving the perpendicular axis untouched', () => { + const out = resizeBbox({ constrain: false, dx: 13, dy: 0, grid: 8, handle: 'e', ratio: 1, snap: true, start }); + expect(out).toEqual(rect(0, 0, 112, 100)); + }); + + it('bypasses snapping when snap is false (alt held)', () => { + const out = resizeBbox({ constrain: false, dx: 13, dy: 0, grid: 8, handle: 'e', ratio: 1, snap: false, start }); + expect(out).toEqual(rect(0, 0, 113, 100)); + }); + + it('clamps to a minimum of one grid cell when collapsing an edge', () => { + const out = resizeBbox({ constrain: false, dx: -500, dy: 0, grid: 8, handle: 'e', ratio: 1, snap: true, start }); + expect(out).toEqual(rect(0, 0, 8, 100)); + }); + + it('never goes below 1px when the grid is sub-pixel', () => { + const out = resizeBbox({ constrain: false, dx: -500, dy: 0, grid: 0, handle: 'e', ratio: 1, snap: false, start }); + expect(out.width).toBe(1); + }); +}); + +describe('resizeBbox — aspect constrained', () => { + const start = rect(0, 0, 100, 100); + + it('preserves the ratio on a corner drag (anchored at the opposite corner)', () => { + const out = resizeBbox({ constrain: true, dx: 100, dy: 0, grid: 8, handle: 'se', ratio: 2, snap: false, start }); + // Pointer widens to 200; ratio 2:1 → height 100. Anchor NW stays at (0,0). + expect(out).toEqual(rect(0, 0, 200, 100)); + }); + + it('re-derives the free axis on an edge drag, keeping the center fixed', () => { + const out = resizeBbox({ constrain: true, dx: 100, dy: 0, grid: 8, handle: 'e', ratio: 2, snap: false, start }); + // width 200 → height 100 (ratio 2), centered on the original vertical center (50). + expect(out).toEqual(rect(0, 0, 200, 100)); + }); + + it('grid-aligns the driven dimension while keeping the ratio exact', () => { + const out = resizeBbox({ constrain: true, dx: 13, dy: 13, grid: 8, handle: 'se', ratio: 1, snap: true, start }); + // se raw ~113 → snap width 112, height = width/ratio = 112. + expect(out.width).toBe(112); + expect(out.height).toBe(112); + }); +}); + +describe('moveBbox', () => { + it('translates and snaps the origin to the grid', () => { + expect(moveBbox(rect(10, 10, 100, 100), 5, 5, 8, true)).toEqual(rect(16, 16, 100, 100)); + }); + it('bypasses snap when snap is false', () => { + expect(moveBbox(rect(10, 10, 100, 100), 5, 5, 8, false)).toEqual(rect(15, 15, 100, 100)); + }); +}); + +describe('constrainBboxToRatio', () => { + it('re-fits to the ratio around the center and snaps the width', () => { + const out = constrainBboxToRatio(rect(0, 0, 100, 100), 2, 8); + expect(out.width / out.height).toBeCloseTo(2, 5); + expect(out.width % 8).toBe(0); + // Center preserved. + expect(out.x + out.width / 2).toBeCloseTo(50, 5); + expect(out.y + out.height / 2).toBeCloseTo(50, 5); + }); +}); + +describe('roundBbox / bboxEquals', () => { + it('rounds to integer document px with a ≥1 size', () => { + expect(roundBbox(rect(1.4, 2.6, 0.2, 10.5))).toEqual(rect(1, 3, 1, 11)); + }); + it('compares equality after rounding', () => { + expect(bboxEquals(rect(0, 0, 100, 100), rect(0.2, -0.1, 100.4, 99.6))).toBe(true); + expect(bboxEquals(rect(0, 0, 100, 100), rect(0, 0, 108, 100))).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxHitTest.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxHitTest.ts new file mode 100644 index 00000000000..1d5066328b4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxHitTest.ts @@ -0,0 +1,280 @@ +/** + * Pure geometry for the bbox (generation-frame) tool: handle layout, screen-space + * hit-testing, and resize/move math. + * + * The bbox is an axis-aligned document-space rectangle. Its eight resize handles + * (four corners + four edge midpoints) are hit-tested in SCREEN space so their + * grab areas stay a constant pixel size regardless of zoom — callers project the + * bbox to screen coordinates first. Resize math runs in DOCUMENT space: the edge + * opposite the grabbed handle stays fixed, the moved edge(s) follow a pointer + * delta, then snapping, min-size clamping, and (optionally) an aspect constraint + * are applied. + * + * Zero React, zero import-time side effects. + */ + +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; + +import { type AspectAnchor, constrainAspect, snapToGrid } from '@workbench/canvas-engine/math/snapping'; + +/** The eight resize handles: four corners + four edge midpoints. */ +export type BboxHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; + +/** What a pointer-down landed on: a resize handle, the interior (move), or nothing. */ +export type BboxTarget = BboxHandle | 'move'; + +/** + * Handles in hit-test priority order: corners first, so at a small bbox where a + * corner and an adjacent edge midpoint overlap, the corner wins. + */ +export const BBOX_HANDLES: readonly BboxHandle[] = ['nw', 'ne', 'se', 'sw', 'n', 'e', 's', 'w']; + +/** Side length (screen px) of a handle's square grab area. */ +export const BBOX_HANDLE_HIT_PX = 14; + +const hasWest = (h: BboxHandle): boolean => h === 'nw' || h === 'w' || h === 'sw'; +const hasEast = (h: BboxHandle): boolean => h === 'ne' || h === 'e' || h === 'se'; +const hasNorth = (h: BboxHandle): boolean => h === 'nw' || h === 'n' || h === 'ne'; +const hasSouth = (h: BboxHandle): boolean => h === 'sw' || h === 's' || h === 'se'; + +/** True for the four corner handles (two-letter ids), false for edge midpoints. */ +export const isCornerHandle = (h: BboxHandle): boolean => h.length === 2; + +/** The point of a handle on `rect` (works in any space — screen or document). */ +export const bboxHandlePoint = (rect: Rect, handle: BboxHandle): Vec2 => { + const x = hasWest(handle) ? rect.x : hasEast(handle) ? rect.x + rect.width : rect.x + rect.width / 2; + const y = hasNorth(handle) ? rect.y : hasSouth(handle) ? rect.y + rect.height : rect.y + rect.height / 2; + return { x, y }; +}; + +/** The corner of `rect` opposite `handle` (stays fixed while `handle` is dragged). */ +const OPPOSITE_CORNER: Record<'nw' | 'ne' | 'se' | 'sw', AspectAnchor> = { + ne: 'sw', + nw: 'se', + se: 'nw', + sw: 'ne', +}; + +/** True when `point` is inside (or on the border of) `rect`. */ +export const isInsideRect = (rect: Rect, point: Vec2): boolean => + point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height; + +/** + * The handle whose square grab area (side `hitSize`, screen px) contains + * `point`, or `null`. `screenRect` is the bbox already projected to screen space. + */ +export const bboxHandleAt = ( + screenRect: Rect, + point: Vec2, + hitSize: number = BBOX_HANDLE_HIT_PX +): BboxHandle | null => { + const half = hitSize / 2; + for (const handle of BBOX_HANDLES) { + const center = bboxHandlePoint(screenRect, handle); + if (Math.abs(point.x - center.x) <= half && Math.abs(point.y - center.y) <= half) { + return handle; + } + } + return null; +}; + +/** + * What a screen-space `point` targets on the bbox: a resize handle (grab area + * first), the interior (`'move'`), or `null` (outside — the bbox is never + * deselected). + */ +export const bboxTargetAt = ( + screenRect: Rect, + point: Vec2, + hitSize: number = BBOX_HANDLE_HIT_PX +): BboxTarget | null => { + const handle = bboxHandleAt(screenRect, point, hitSize); + if (handle) { + return handle; + } + return isInsideRect(screenRect, point) ? 'move' : null; +}; + +/** Minimum bbox side (document px): one grid cell, but never below 1px. */ +export const bboxMinSize = (grid: number): number => Math.max(1, grid); + +/** Translates `start` by a document-space delta, snapping the origin to `grid` unless bypassed. */ +export const moveBbox = (start: Rect, dx: number, dy: number, grid: number, snap: boolean): Rect => { + let x = start.x + dx; + let y = start.y + dy; + if (snap) { + x = snapToGrid(x, grid); + y = snapToGrid(y, grid); + } + return { height: start.height, width: start.width, x, y }; +}; + +export interface ResizeBboxParams { + /** The bbox at gesture start. */ + start: Rect; + handle: BboxHandle; + /** Document-space pointer delta since gesture start. */ + dx: number; + dy: number; + /** Model grid size (document px). */ + grid: number; + /** Whether to snap moved edges to the grid (false = alt-bypass). */ + snap: boolean; + /** Whether to preserve `ratio` (aspect lock, or shift). */ + constrain: boolean; + /** Target width / height ratio, used when `constrain`. */ + ratio: number; +} + +/** Builds a rect from a fixed anchor corner extending by `width`/`height` in the handle's directions. */ +const rectFromCorner = (fixed: Vec2, width: number, height: number, signX: number, signY: number): Rect => { + const x = signX >= 0 ? fixed.x : fixed.x - width; + const y = signY >= 0 ? fixed.y : fixed.y - height; + return { height, width, x, y }; +}; + +/** Unconstrained resize: moved edges follow the delta; the opposite edge stays fixed. */ +const resizeFree = (p: ResizeBboxParams): Rect => { + const { dx, dy, grid, handle, snap, start } = p; + let left = start.x; + let top = start.y; + let right = start.x + start.width; + let bottom = start.y + start.height; + + if (hasWest(handle)) { + left = snap ? snapToGrid(left + dx, grid) : left + dx; + } + if (hasEast(handle)) { + right = snap ? snapToGrid(right + dx, grid) : right + dx; + } + if (hasNorth(handle)) { + top = snap ? snapToGrid(top + dy, grid) : top + dy; + } + if (hasSouth(handle)) { + bottom = snap ? snapToGrid(bottom + dy, grid) : bottom + dy; + } + + const min = bboxMinSize(grid); + // Clamp so the moved edge never crosses within `min` of the fixed edge. + if (hasWest(handle)) { + left = Math.min(left, right - min); + } + if (hasEast(handle)) { + right = Math.max(right, left + min); + } + if (hasNorth(handle)) { + top = Math.min(top, bottom - min); + } + if (hasSouth(handle)) { + bottom = Math.max(bottom, top + min); + } + + return { height: bottom - top, width: right - left, x: left, y: top }; +}; + +/** Aspect-constrained corner resize: anchored at the opposite corner, grid-aligning width. */ +const resizeCornerConstrained = (p: ResizeBboxParams): Rect => { + const { dx, dy, grid, handle, ratio, snap, start } = p; + const anchorCorner = OPPOSITE_CORNER[handle as 'nw' | 'ne' | 'se' | 'sw']; + const fixed = bboxHandlePoint(start, anchorCorner as BboxHandle); + const moveStart = bboxHandlePoint(start, handle); + const mx = moveStart.x + dx; + const my = moveStart.y + dy; + const signX = moveStart.x >= fixed.x ? 1 : -1; + const signY = moveStart.y >= fixed.y ? 1 : -1; + + const rawW = Math.abs(mx - fixed.x); + const rawH = Math.abs(my - fixed.y); + + // Pick the driven axis: whichever keeps the frame closest to the pointer. + let width = rawW / rawH >= ratio ? rawW : rawH * ratio; + const min = bboxMinSize(grid); + width = snap ? Math.max(min, snapToGrid(width, grid)) : Math.max(min, width); + let height = width / ratio; + if (height < 1) { + height = 1; + width = height * ratio; + } + return rectFromCorner(fixed, width, height, signX, signY); +}; + +/** Aspect-constrained edge resize: the free axis follows the delta, the other axis re-derives from `ratio`, centered. */ +const resizeEdgeConstrained = (p: ResizeBboxParams): Rect => { + const { dx, dy, grid, handle, ratio, snap, start } = p; + const min = bboxMinSize(grid); + const left = start.x; + const right = start.x + start.width; + const top = start.y; + const bottom = start.y + start.height; + const centerX = start.x + start.width / 2; + const centerY = start.y + start.height / 2; + + if (handle === 'e' || handle === 'w') { + let width: number; + if (handle === 'e') { + const edge = snap ? snapToGrid(right + dx, grid) : right + dx; + width = Math.max(min, edge - left); + } else { + const edge = snap ? snapToGrid(left + dx, grid) : left + dx; + width = Math.max(min, right - edge); + } + const height = Math.max(1, width / ratio); + const x = handle === 'e' ? left : right - width; + return { height, width, x, y: centerY - height / 2 }; + } + + // 'n' | 's': height-driven. + let height: number; + if (handle === 's') { + const edge = snap ? snapToGrid(bottom + dy, grid) : bottom + dy; + height = Math.max(min, edge - top); + } else { + const edge = snap ? snapToGrid(top + dy, grid) : top + dy; + height = Math.max(min, bottom - edge); + } + const width = Math.max(1, height * ratio); + const y = handle === 's' ? top : bottom - height; + return { height, width, x: centerX - width / 2, y }; +}; + +/** + * Resizes `start` for a handle drag. Applies (in order) the delta to the moved + * edge(s), grid snapping (unless bypassed), min-size clamping, and — when + * `constrain` — an aspect-ratio constraint anchored at the opposite corner + * (corners) or the center (edges). + */ +export const resizeBbox = (p: ResizeBboxParams): Rect => { + if (p.constrain && p.ratio > 0) { + return isCornerHandle(p.handle) ? resizeCornerConstrained(p) : resizeEdgeConstrained(p); + } + return resizeFree(p); +}; + +/** Re-fits `rect` to `ratio`, keeping its center fixed, then snaps to `grid` and clamps min size. */ +export const constrainBboxToRatio = (rect: Rect, ratio: number, grid: number): Rect => { + if (ratio <= 0) { + return rect; + } + const fitted = constrainAspect(rect, ratio, 'center'); + const min = bboxMinSize(grid); + const width = Math.max(min, snapToGrid(fitted.width, grid)); + const height = Math.max(1, width / ratio); + const centerX = rect.x + rect.width / 2; + const centerY = rect.y + rect.height / 2; + return { height, width, x: centerX - width / 2, y: centerY - height / 2 }; +}; + +/** Rounds a rect's fields to integers (document px), enforcing a ≥1px size. */ +export const roundBbox = (rect: Rect): Rect => ({ + height: Math.max(1, Math.round(rect.height)), + width: Math.max(1, Math.round(rect.width)), + x: Math.round(rect.x), + y: Math.round(rect.y), +}); + +/** True when two rects are equal after rounding to integer document px. */ +export const bboxEquals = (a: Rect, b: Rect): boolean => { + const ra = roundBbox(a); + const rb = roundBbox(b); + return ra.x === rb.x && ra.y === rb.y && ra.width === rb.width && ra.height === rb.height; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxTool.test.ts new file mode 100644 index 00000000000..6987f3f2612 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxTool.test.ts @@ -0,0 +1,232 @@ +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { Viewport } from '@workbench/canvas-engine/viewport'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { describe, expect, it, vi } from 'vitest'; + +import { createBboxTool } from './bboxTool'; + +// Grid-aligned start frame (grid 8) so a zero-net-move gesture is a true no-op. +const bbox = { height: 96, width: 96, x: 16, y: 16 }; + +const makeDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { ...bbox }, + height: 512, + layers: [], + selectedLayerId: null, + version: 2, + width: 512, +}); + +// Identity viewport: screen coordinates equal document coordinates (zoom 1, no pan), +// so pointer inputs can use the same value for both spaces. +const identityViewport = { + documentToScreen: (p: Vec2): Vec2 => ({ x: p.x, y: p.y }), +} as unknown as Viewport; + +const pointer = ( + x: number, + y: number, + opts: { shift?: boolean; alt?: boolean; buttons?: number } = {} +): PointerInput => ({ + buttons: opts.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: opts.alt ?? false, ctrl: false, meta: false, shift: opts.shift ?? false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +interface StructuralCommit { + label: string; + forward: CanvasProjectMutation; + inverse: CanvasProjectMutation; +} + +interface Harness { + ctx: ToolContext; + dispatched: CanvasProjectMutation[]; + commits: StructuralCommit[]; + previewOf: () => CanvasDocumentContractV2['bbox'] | null; +} + +const createHarness = (doc: CanvasDocumentContractV2): Harness => { + const dispatched: CanvasProjectMutation[] = []; + const commits: StructuralCommit[] = []; + const stores = createEngineStores(); + const ctx: ToolContext = { + backend: null as never, + commitStructural: (label, forward, inverse) => commits.push({ forward, inverse, label }), + createLayerId: () => 'x', + createPath2D: (d) => ({ d }) as unknown as Path2D, + dispatch: (action) => dispatched.push(action), + emitStrokeCommitted: vi.fn(), + getDocument: () => doc, + invalidate: vi.fn(), + layers: null as never, + notifyLayerPainted: vi.fn(), + setLayerTransformOverride: vi.fn(), + setOverlayCursor: vi.fn(), + stores, + updateCursor: vi.fn(), + viewport: identityViewport, + }; + return { commits, ctx, dispatched, previewOf: () => stores.bboxPreview.get() }; +}; + +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerMove?.(ctx, i, [i]); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); +const cancel = (t: Tool, ctx: ToolContext): void => t.onPointerCancel?.(ctx); + +describe('bbox tool: move gesture', () => { + it('previews on move then commits one setCanvasBbox on up (grid-snapped)', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + + down(tool, h.ctx, pointer(60, 60)); // inside the frame → move + move(tool, h.ctx, pointer(75, 75)); // delta (15,15) → moved + expect(h.previewOf()).not.toBeNull(); + + up(tool, h.ctx, pointer(75, 75)); + + expect(h.dispatched).toHaveLength(0); + expect(h.commits).toHaveLength(1); + // origin snapped: 16+15 = 31 → grid 8 → 32. + expect(h.commits[0]?.forward).toEqual({ bbox: { height: 96, width: 96, x: 32, y: 32 }, type: 'setCanvasBbox' }); + expect(h.commits[0]?.inverse).toEqual({ bbox: { ...bbox }, type: 'setCanvasBbox' }); + // Preview cleared after commit. + expect(h.previewOf()).toBeNull(); + }); + + it('bypasses snapping while alt is held', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + down(tool, h.ctx, pointer(60, 60, { alt: true })); + move(tool, h.ctx, pointer(75, 75, { alt: true })); + up(tool, h.ctx, pointer(75, 75, { alt: true })); + expect(h.commits[0]?.forward).toEqual({ bbox: { height: 96, width: 96, x: 31, y: 31 }, type: 'setCanvasBbox' }); + }); +}); + +describe('bbox tool: resize gesture', () => { + it('resizes from the SE handle and commits the grid-snapped frame', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + + down(tool, h.ctx, pointer(112, 112)); // SE corner handle + move(tool, h.ctx, pointer(130, 130)); // delta (18,18) + up(tool, h.ctx, pointer(130, 130)); + + expect(h.commits).toHaveLength(1); + // right/bottom = 112+18 = 130 → grid 8 → 128; anchored at (16,16). + expect(h.commits[0]?.forward).toEqual({ bbox: { height: 112, width: 112, x: 16, y: 16 }, type: 'setCanvasBbox' }); + }); + + it('constrains the ratio while shift is held on an unlocked frame', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + // Start frame is square (96x96) → shift preserves 1:1. + down(tool, h.ctx, pointer(112, 112)); + move(tool, h.ctx, pointer(150, 112, { shift: true })); // widen only, ratio 1:1 keeps square + up(tool, h.ctx, pointer(150, 112, { shift: true })); + const out = h.commits[0]?.forward as { bbox: { width: number; height: number } }; + expect(out.bbox.width).toBe(out.bbox.height); + }); +}); + +describe('bbox tool: no-op cases', () => { + it('commits nothing on a zero-delta gesture that returns to the origin', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + down(tool, h.ctx, pointer(60, 60)); + move(tool, h.ctx, pointer(70, 70)); // crosses threshold → moved + move(tool, h.ctx, pointer(60, 60)); // back to origin + up(tool, h.ctx, pointer(60, 60)); + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toHaveLength(0); + expect(h.previewOf()).toBeNull(); + }); + + it('does nothing when pressing outside the frame (never deselects)', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + down(tool, h.ctx, pointer(400, 400)); + move(tool, h.ctx, pointer(420, 420)); + up(tool, h.ctx, pointer(420, 420)); + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toHaveLength(0); + expect(h.previewOf()).toBeNull(); + }); + + it('ignores a sub-threshold press (no drag)', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + down(tool, h.ctx, pointer(60, 60)); + up(tool, h.ctx, pointer(61, 61)); + expect(h.commits).toHaveLength(0); + }); +}); + +describe('bbox tool: cancel', () => { + it('drops the preview and commits nothing on escape/cancel', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + down(tool, h.ctx, pointer(112, 112)); + move(tool, h.ctx, pointer(130, 130)); + expect(h.previewOf()).not.toBeNull(); + cancel(tool, h.ctx); + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toHaveLength(0); + expect(h.previewOf()).toBeNull(); + }); +}); + +describe('bbox tool: hover cursors', () => { + it('reflects the hovered handle/interior in the cursor and refreshes it on change', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + // Hover (no gesture) then read the tool's CSS cursor. Frame is {16,16,96,96}. + const cursorAt = (x: number, y: number): string | undefined => { + move(tool, h.ctx, pointer(x, y)); + return tool.cursor?.(h.ctx); + }; + + expect(cursorAt(112, 112)).toBe('nwse-resize'); // SE corner + expect(cursorAt(16, 112)).toBe('nesw-resize'); // SW corner + expect(cursorAt(16, 16)).toBe('nwse-resize'); // NW corner + expect(cursorAt(112, 16)).toBe('nesw-resize'); // NE corner + expect(cursorAt(64, 16)).toBe('ns-resize'); // N edge midpoint + expect(cursorAt(112, 64)).toBe('ew-resize'); // E edge midpoint + expect(cursorAt(64, 64)).toBe('move'); // interior + expect(cursorAt(400, 400)).toBe('default'); // off the frame + + // The tool asked the engine to re-apply the cursor as the hover target changed. + expect(h.ctx.updateCursor).toHaveBeenCalled(); + }); + + it('holds the grabbed handle cursor during a resize drag', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + down(tool, h.ctx, pointer(112, 112)); // grab SE corner + move(tool, h.ctx, pointer(130, 130)); // dragging + expect(tool.cursor?.(h.ctx)).toBe('nwse-resize'); + }); +}); + +describe('bbox tool: undo/redo contract', () => { + it('carries an inverse that restores the prior bbox', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + down(tool, h.ctx, pointer(112, 112)); + move(tool, h.ctx, pointer(130, 130)); + up(tool, h.ctx, pointer(130, 130)); + // The engine's commitStructural dispatches `inverse` on undo — restoring the original frame. + expect(h.commits[0]?.inverse).toEqual({ bbox: { ...bbox }, type: 'setCanvasBbox' }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxTool.ts new file mode 100644 index 00000000000..44015ceabf5 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxTool.ts @@ -0,0 +1,203 @@ +/** + * The bbox tool: interactive editing of the generation bounding box + * (`document.bbox`). + * + * Interaction contract (CANVAS_PLAN Phase 4.1): + * - **Pointer-down** hit-tests the bbox: on a resize handle → a resize gesture; + * inside the frame → a move gesture; outside → nothing (the bbox is never + * "deselected"). + * - **Pointer-move** updates an engine-transient preview rect (via + * `stores.bboxPreview`) that the overlay renders — it never dispatches. Sizes + * and positions snap to the model grid (`stores.bboxGrid`); hold **alt** to + * bypass snapping. Corner/edge resize preserves the aspect ratio when the lock + * is active (`stores.bboxOptions`); **shift** toggles the constraint on for an + * unlocked frame (a locked ratio stays locked). + * - **Commit** (pointer-up after a real change): exactly one `commitStructural` + * with the new/old bbox (`setCanvasBbox`, undoable). A zero-delta gesture + * commits nothing. + * - **Cancel** (Esc / pointercancel): drops the preview, no dispatch. + * + * Zero React, zero import-time side effects. + */ + +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; + +import type { Tool, ToolContext } from './tool'; + +import { type BboxTarget, bboxEquals, bboxTargetAt, moveBbox, resizeBbox, roundBbox } from './bboxHitTest'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** + * The CSS cursor for each bbox target: axis-aware resize arrows on the handles + * (opposite corners/edges share a cursor) and `move` inside the frame. + */ +const CURSOR_FOR_TARGET: Record = { + e: 'ew-resize', + move: 'move', + n: 'ns-resize', + ne: 'nesw-resize', + nw: 'nwse-resize', + s: 'ns-resize', + se: 'nwse-resize', + sw: 'nesw-resize', + w: 'ew-resize', +}; + +/** Screen-space distance (CSS px) the pointer must travel before a press becomes a drag. */ +export const BBOX_DRAG_THRESHOLD_PX = 3; + +interface GestureState { + target: BboxTarget; + /** The bbox at gesture start (document space). */ + startBbox: Rect; + /** The pointer's document/screen position at gesture start. */ + startDoc: Vec2; + startScreen: Vec2; + moved: boolean; +} + +/** The bbox projected to screen space (axis-aligned; the view has no rotation). */ +const bboxToScreen = (ctx: ToolContext, bbox: Rect): Rect => { + const topLeft = ctx.viewport.documentToScreen({ x: bbox.x, y: bbox.y }); + const bottomRight = ctx.viewport.documentToScreen({ x: bbox.x + bbox.width, y: bbox.y + bbox.height }); + return { + height: bottomRight.y - topLeft.y, + width: bottomRight.x - topLeft.x, + x: topLeft.x, + y: topLeft.y, + }; +}; + +/** Computes the next bbox for a gesture from the current pointer, applying snap/aspect rules. */ +const nextBboxFor = ( + ctx: ToolContext, + state: GestureState, + point: Vec2, + modifiers: { alt: boolean; shift: boolean } +): Rect => { + const grid = ctx.stores.bboxGrid.get(); + // Snap to the model grid unless Alt bypasses it or the snap-to-grid setting is off. + const snap = !modifiers.alt && ctx.stores.snapToGrid.get(); + const dx = point.x - state.startDoc.x; + const dy = point.y - state.startDoc.y; + + if (state.target === 'move') { + return moveBbox(state.startBbox, dx, dy, grid, snap); + } + + const options = ctx.stores.bboxOptions.get(); + const constrain = options.aspectLocked || modifiers.shift; + // Locked → the option ratio; unlocked+shift → preserve the frame's own ratio. + const ratio = options.aspectLocked + ? options.aspectRatio + : state.startBbox.height > 0 + ? state.startBbox.width / state.startBbox.height + : 1; + + return resizeBbox({ constrain, dx, dy, grid, handle: state.target, ratio, snap, start: state.startBbox }); +}; + +/** Creates a fresh bbox tool with its own gesture state. */ +export const createBboxTool = (): Tool => { + let state: GestureState | null = null; + // The bbox target under the pointer while idle (no gesture), driving the hover + // resize/move cursor. `null` = the pointer is off the frame. + let hoverTarget: BboxTarget | null = null; + + const clearPreview = (ctx: ToolContext): void => { + ctx.stores.bboxPreview.set(null); + ctx.invalidate({ overlay: true }); + }; + + return { + // During a drag the grabbed target's cursor holds; while idle the hovered + // handle/interior wins; off the frame it falls back to the default arrow. + cursor: () => { + const target = state?.target ?? hoverTarget; + return target ? CURSOR_FOR_TARGET[target] : 'default'; + }, + id: 'bbox', + onDeactivate: (ctx) => { + state = null; + hoverTarget = null; + clearPreview(ctx); + }, + onPointerCancel: (ctx) => { + state = null; + clearPreview(ctx); + }, + onPointerDown: (ctx, input) => { + if (state || (input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + const doc = ctx.getDocument(); + if (!doc) { + return; + } + const target = bboxTargetAt(bboxToScreen(ctx, doc.bbox), input.screenPoint); + if (!target) { + // Outside the frame: the bbox is never deselected, so do nothing. + return; + } + state = { + moved: false, + startBbox: doc.bbox, + startDoc: input.documentPoint, + startScreen: input.screenPoint, + target, + }; + }, + onPointerMove: (ctx, input) => { + if (!state) { + // Idle hover: reflect the handle/interior under the pointer in the cursor. + const doc = ctx.getDocument(); + const target = doc ? bboxTargetAt(bboxToScreen(ctx, doc.bbox), input.screenPoint) : null; + if (target !== hoverTarget) { + hoverTarget = target; + ctx.updateCursor(); + } + return; + } + if (!state.moved) { + const dxs = input.screenPoint.x - state.startScreen.x; + const dys = input.screenPoint.y - state.startScreen.y; + if (Math.hypot(dxs, dys) < BBOX_DRAG_THRESHOLD_PX) { + return; + } + state.moved = true; + } + const next = nextBboxFor(ctx, state, input.documentPoint, input.modifiers); + ctx.stores.bboxPreview.set(next); + ctx.invalidate({ overlay: true }); + }, + onPointerUp: (ctx, input) => { + if (!state) { + return; + } + const current = state; + state = null; + + if (!current.moved) { + clearPreview(ctx); + return; + } + + const next = roundBbox(nextBboxFor(ctx, current, input.documentPoint, input.modifiers)); + if (bboxEquals(next, current.startBbox)) { + // Zero-delta gesture: nothing to commit. + clearPreview(ctx); + return; + } + + ctx.commitStructural( + 'Set generation frame', + { bbox: next, type: 'setCanvasBbox' }, + { bbox: roundBbox(current.startBbox), type: 'setCanvasBbox' } + ); + // The committed bbox now flows through the mirror; drop the preview. + clearPreview(ctx); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/brushTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/brushTool.ts new file mode 100644 index 00000000000..2d99e2ee339 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/brushTool.ts @@ -0,0 +1,26 @@ +/** + * The brush tool: paints variable-width, pressure-sensitive strokes in the brush + * color into the target paint layer's cache surface. It is a thin binding over + * {@link createPaintTool} that sources its size/color/opacity/pressure from the + * engine's `brushOptions` store; all gesture and pixel logic lives in the shared + * paint tool and {@link createStrokeSession}. + * + * Each engine builds its own instance so gesture state is per-engine. Zero React, + * zero import-time side effects. + */ + +import { PRESSURE_THINNING } from '@workbench/canvas-engine/tools/paintConstants'; +import { createPaintTool } from '@workbench/canvas-engine/tools/paintTool'; + +import type { Tool } from './tool'; + +/** Creates a fresh brush tool with its own gesture state. */ +export const createBrushTool = (): Tool => + createPaintTool({ + color: (ctx) => ctx.stores.brushOptions.get().color, + composite: 'source-over', + id: 'brush', + opacity: (ctx) => ctx.stores.brushOptions.get().opacity, + size: (ctx) => ctx.stores.brushOptions.get().size, + thinning: (ctx) => (ctx.stores.brushOptions.get().pressureSensitivity ? PRESSURE_THINNING : 0), + }); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.test.ts new file mode 100644 index 00000000000..4847530b031 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.test.ts @@ -0,0 +1,192 @@ +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { createLayerCacheStore, type LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { describe, expect, it, vi } from 'vitest'; + +import { createColorPickerTool } from './colorPickerTool'; + +/** A mutable RGBA pixel a test can change between gesture steps, injected into every `getImageData` call. */ +type MutablePixel = { current: readonly [number, number, number, number] }; + +/** A minimal `RasterBackend` whose scratch surfaces report `pixel.current` for every `getImageData` call. */ +const createFixedPixelBackend = (pixel: MutablePixel): RasterBackend => ({ + createImageBitmap: () => Promise.resolve({} as ImageBitmap), + createSurface: (width: number, height: number): RasterSurface => { + const canvas = { height, width } as unknown as OffscreenCanvas; + const ctx = { + clearRect: () => {}, + drawImage: () => {}, + getImageData: () => + ({ data: Uint8ClampedArray.from(pixel.current), height: 1, width: 1 }) as unknown as ImageData, + restore: () => {}, + save: () => {}, + setTransform: () => {}, + } as unknown as OffscreenCanvasRenderingContext2D; + return { canvas, ctx, height, resize: () => {}, width }; + }, + encodeSurface: () => Promise.resolve(new Blob()), +}); + +const paintLayer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const makeDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [paintLayer('paint1')], + selectedLayerId: 'paint1', + version: 2, + width: 100, +}); + +const pointer = (x: number, y: number, opts: { buttons?: number } = {}): PointerInput => ({ + buttons: opts.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: true, ctrl: false, meta: false, shift: false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +interface Harness { + ctx: ToolContext; + pixel: MutablePixel; + layers: LayerCacheStore; + dispatched: CanvasProjectMutation[]; + strokes: unknown[]; + overlayCursors: unknown[]; +} + +const createHarness = (doc: CanvasDocumentContractV2 | null): Harness => { + const pixel: MutablePixel = { current: [10, 20, 30, 255] }; + const backend = createFixedPixelBackend(pixel); + const layers = createLayerCacheStore(backend); + if (doc) { + layers.getOrCreate('paint1', doc.width, doc.height); + } + const stores = createEngineStores(); + const dispatched: CanvasProjectMutation[] = []; + const strokes: unknown[] = []; + const overlayCursors: unknown[] = []; + + const ctx: ToolContext = { + backend, + commitStructural: vi.fn(), + createLayerId: () => 'unused', + createPath2D: (d) => ({ d }) as unknown as Path2D, + dispatch: (action) => dispatched.push(action), + emitStrokeCommitted: (event) => strokes.push(event), + getDocument: () => doc, + invalidate: vi.fn(), + layers, + notifyLayerPainted: vi.fn(), + setLayerTransformOverride: vi.fn(), + setOverlayCursor: (cursor) => overlayCursors.push(cursor), + stores, + updateCursor: vi.fn(), + viewport: { getZoom: () => 1 } as unknown as ToolContext['viewport'], + }; + + return { ctx, dispatched, layers, overlayCursors, pixel, strokes }; +}; + +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerMove?.(ctx, i, [i]); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); + +describe('color picker tool', () => { + it('samples the composited color on pointer down and writes it into brushOptions.color', () => { + const h = createHarness(makeDoc()); + const tool = createColorPickerTool(); + + down(tool, h.ctx, pointer(10, 10)); + + expect(h.ctx.stores.brushOptions.get().color).toBe('#0a141e'); + }); + + it('re-samples on drag (primary button held) as the sample changes', () => { + const h = createHarness(makeDoc()); + const tool = createColorPickerTool(); + + down(tool, h.ctx, pointer(10, 10)); + expect(h.ctx.stores.brushOptions.get().color).toBe('#0a141e'); + + h.pixel.current = [40, 50, 60, 255]; + move(tool, h.ctx, pointer(20, 20)); + expect(h.ctx.stores.brushOptions.get().color).toBe('#28323c'); + }); + + it('does not sample on move when no button is held', () => { + const h = createHarness(makeDoc()); + const tool = createColorPickerTool(); + const defaultColor = h.ctx.stores.brushOptions.get().color; + + move(tool, h.ctx, pointer(10, 10, { buttons: 0 })); + + expect(h.ctx.stores.brushOptions.get().color).toBe(defaultColor); + }); + + it('leaves brushOptions untouched when the point falls outside the document', () => { + const h = createHarness(makeDoc()); + const tool = createColorPickerTool(); + const defaultColor = h.ctx.stores.brushOptions.get().color; + + down(tool, h.ctx, pointer(-5, 10)); + + expect(h.ctx.stores.brushOptions.get().color).toBe(defaultColor); + }); + + it('leaves brushOptions untouched when there is no document', () => { + const h = createHarness(null); + const tool = createColorPickerTool(); + const defaultColor = h.ctx.stores.brushOptions.get().color; + + down(tool, h.ctx, pointer(10, 10)); + + expect(h.ctx.stores.brushOptions.get().color).toBe(defaultColor); + }); + + it('never dispatches and never emits a committed stroke', () => { + const h = createHarness(makeDoc()); + const tool = createColorPickerTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(20, 20)); + up(tool, h.ctx, pointer(20, 20)); + + expect(h.dispatched).toHaveLength(0); + expect(h.strokes).toHaveLength(0); + }); + + it('shows a ring cursor while active and clears it on deactivate', () => { + const h = createHarness(makeDoc()); + const tool = createColorPickerTool(); + + move(tool, h.ctx, pointer(10, 10, { buttons: 0 })); + expect(h.overlayCursors.at(-1)).toMatchObject({ point: { x: 10, y: 10 } }); + + tool.onDeactivate?.(h.ctx); + expect(h.overlayCursors.at(-1)).toBeNull(); + }); + + it('reports a crosshair cursor', () => { + const tool = createColorPickerTool(); + expect(tool.cursor?.({} as ToolContext)).toBe('crosshair'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.ts new file mode 100644 index 00000000000..6d0b0cf5b28 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.ts @@ -0,0 +1,74 @@ +/** + * The color-picker tool: while held down (usually via the alt-hold temp-tool + * switch the pointer pipeline already drives — see `input/pointerPipeline.ts`), + * it samples the composited document color under the cursor and writes it into + * the brush color option, so releasing alt drops the user right back into + * painting with the picked color. Sampling reads the layer cache directly + * through {@link sampleDocumentColor} — this tool never dispatches and never + * touches pixels. + * + * Each engine builds its own instance so cursor-ring state is per-engine. + * Zero React, zero import-time side effects. + */ + +import type { PointerInput } from '@workbench/canvas-engine/types'; + +import { rgbaToHex } from '@workbench/canvas-engine/color'; +import { sampleDocumentColor } from '@workbench/canvas-engine/render/colorSample'; + +import type { Tool, ToolContext } from './tool'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** Fixed on-screen radius (px) for the picker's ring cursor, independent of zoom. */ +const CURSOR_SCREEN_RADIUS_PX = 8; + +const updateCursorRing = (ctx: ToolContext, input: PointerInput): void => { + const zoom = ctx.viewport.getZoom(); + ctx.setOverlayCursor({ point: input.documentPoint, radiusDoc: CURSOR_SCREEN_RADIUS_PX / Math.max(zoom, 1e-6) }); + ctx.invalidate({ overlay: true }); +}; + +/** Samples the composited color under `input` and writes it into the brush color option, if pickable. */ +const pickColorAt = (ctx: ToolContext, input: PointerInput): void => { + const doc = ctx.getDocument(); + if (!doc) { + return; + } + const sample = sampleDocumentColor(doc, ctx.layers, ctx.backend, input.documentPoint); + if (!sample) { + return; + } + const hex = rgbaToHex(sample.r, sample.g, sample.b); + const opts = ctx.stores.brushOptions.get(); + if (hex !== opts.color) { + ctx.stores.brushOptions.set({ ...opts, color: hex }); + } +}; + +/** Creates a fresh color-picker tool. */ +export const createColorPickerTool = (): Tool => ({ + cursor: () => 'crosshair', + id: 'colorPicker', + onDeactivate: (ctx) => { + ctx.setOverlayCursor(null); + ctx.invalidate({ overlay: true }); + }, + onPointerDown: (ctx, input) => { + if ((input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + updateCursorRing(ctx, input); + pickColorAt(ctx, input); + }, + onPointerMove: (ctx, input) => { + updateCursorRing(ctx, input); + if (input.buttons & PRIMARY_BUTTON) { + pickColorAt(ctx, input); + } + }, + onPointerUp: (ctx, input) => { + updateCursorRing(ctx, input); + }, +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/eraserTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/eraserTool.ts new file mode 100644 index 00000000000..8914ae61166 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/eraserTool.ts @@ -0,0 +1,25 @@ +/** + * The eraser tool: clears pixels along a stroke by compositing the same freehand + * shape as the brush with `destination-out` into the target paint layer's cache. + * A thin binding over {@link createPaintTool} sourcing size/opacity from the + * engine's `eraserOptions` store; the eraser is pressure-insensitive (thinning 0) + * and has no color (the shape alone drives the erase). + * + * Each engine builds its own instance so gesture state is per-engine. Zero React, + * zero import-time side effects. + */ + +import { createPaintTool } from '@workbench/canvas-engine/tools/paintTool'; + +import type { Tool } from './tool'; + +/** Creates a fresh eraser tool with its own gesture state. */ +export const createEraserTool = (): Tool => + createPaintTool({ + color: () => '#000000', + composite: 'destination-out', + id: 'eraser', + opacity: (ctx) => ctx.stores.eraserOptions.get().opacity, + size: (ctx) => ctx.stores.eraserOptions.get().size, + thinning: () => 0, + }); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/gradientTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/gradientTool.test.ts new file mode 100644 index 00000000000..348b8f279dc --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/gradientTool.test.ts @@ -0,0 +1,221 @@ +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { Viewport } from '@workbench/canvas-engine/viewport'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { describe, expect, it, vi } from 'vitest'; + +import { angleFromDrag, createGradientTool } from './gradientTool'; + +const gradientLayer = (over: Partial = {}): CanvasLayerContract => + ({ + blendMode: 'normal', + id: 'grad-existing', + isEnabled: true, + isLocked: false, + name: 'Gradient', + opacity: 1, + source: { + angle: 0, + kind: 'linear', + stops: [ + { color: '#000000', offset: 0 }, + { color: '#ffffff', offset: 1 }, + ], + type: 'gradient', + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...over, + }) as CanvasLayerContract; + +const makeDoc = (over: Partial = {}): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 96, width: 96, x: 0, y: 0 }, + height: 512, + layers: [], + selectedLayerId: null, + version: 2, + width: 512, + ...over, +}); + +const identityViewport = { + documentToScreen: (p: Vec2): Vec2 => ({ x: p.x, y: p.y }), +} as unknown as Viewport; + +const pointer = (x: number, y: number, buttons = 1): PointerInput => ({ + buttons, + documentPoint: { x, y }, + modifiers: { alt: false, ctrl: false, meta: false, shift: false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +interface StructuralCommit { + label: string; + forward: CanvasProjectMutation; + inverse: CanvasProjectMutation; +} + +const createHarness = (doc: CanvasDocumentContractV2) => { + const dispatched: CanvasProjectMutation[] = []; + const commits: StructuralCommit[] = []; + const stores = createEngineStores(); + let idCounter = 0; + const ctx: ToolContext = { + backend: null as never, + commitStructural: (label, forward, inverse) => commits.push({ forward, inverse, label }), + createLayerId: () => `grad-${++idCounter}`, + createPath2D: (d) => ({ d }) as unknown as Path2D, + dispatch: (action) => dispatched.push(action), + emitStrokeCommitted: vi.fn(), + getDocument: () => doc, + invalidate: vi.fn(), + layers: null as never, + notifyLayerPainted: vi.fn(), + setLayerTransformOverride: vi.fn(), + setOverlayCursor: vi.fn(), + stores, + updateCursor: vi.fn(), + viewport: identityViewport, + }; + return { commits, ctx, dispatched, previewOf: () => stores.gradientPreview.get(), stores }; +}; + +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerMove?.(ctx, i, [i]); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); + +describe('angleFromDrag', () => { + it('is 0° for a left→right drag and 90° for a top→bottom drag', () => { + expect(angleFromDrag({ x: 0, y: 0 }, { x: 10, y: 0 })).toBeCloseTo(0); + expect(angleFromDrag({ x: 0, y: 0 }, { x: 0, y: 10 })).toBeCloseTo(90); + }); +}); + +describe('gradient tool: create when no gradient selected', () => { + it('creates a document-covering gradient layer with the drag angle', () => { + const h = createHarness(makeDoc()); + const tool = createGradientTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(10, 110)); + expect(h.previewOf()).toEqual({ end: { x: 10, y: 110 }, start: { x: 10, y: 10 } }); + + up(tool, h.ctx, pointer(10, 110)); + + expect(h.dispatched).toHaveLength(0); + expect(h.commits).toHaveLength(1); + const forward = h.commits[0]?.forward; + expect(forward?.type).toBe('addCanvasLayer'); + if ( + forward?.type === 'addCanvasLayer' && + forward.layer.type === 'raster' && + forward.layer.source.type === 'gradient' + ) { + expect(forward.layer.source.angle).toBeCloseTo(90); + expect(forward.layer.source.kind).toBe('linear'); + expect(forward.layer.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }); + } else { + throw new Error('expected a gradient layer'); + } + expect(h.commits[0]?.inverse).toEqual({ ids: ['grad-1'], type: 'removeCanvasLayers' }); + expect(h.previewOf()).toBeNull(); + }); +}); + +describe('gradient tool: edit selected gradient layer', () => { + it('commits ONE updateCanvasLayerSource with the new angle (kind/stops preserved)', () => { + const layer = gradientLayer(); + const doc = makeDoc({ layers: [layer], selectedLayerId: 'grad-existing' }); + const h = createHarness(doc); + const tool = createGradientTool(); + + down(tool, h.ctx, pointer(0, 0)); + move(tool, h.ctx, pointer(100, 0)); + up(tool, h.ctx, pointer(100, 0)); + + expect(h.commits).toHaveLength(1); + const forward = h.commits[0]?.forward; + const inverse = h.commits[0]?.inverse; + expect(forward?.type).toBe('updateCanvasLayerSource'); + if (forward?.type === 'updateCanvasLayerSource' && forward.source.type === 'gradient') { + expect(forward.id).toBe('grad-existing'); + expect(forward.source.angle).toBeCloseTo(0); + expect(forward.source.kind).toBe('linear'); + expect(forward.source.stops).toHaveLength(2); + } else { + throw new Error('expected an updateCanvasLayerSource gradient edit'); + } + // Inverse restores the exact original source object. + if (inverse?.type === 'updateCanvasLayerSource' && layer.type === 'raster') { + expect(inverse.source).toBe(layer.source); + } else { + throw new Error('expected an inverse source restore'); + } + }); + + it('is a no-op when the selected gradient layer is locked', () => { + const layer = gradientLayer({ isLocked: true }); + const doc = makeDoc({ layers: [layer], selectedLayerId: 'grad-existing' }); + const h = createHarness(doc); + const tool = createGradientTool(); + + down(tool, h.ctx, pointer(0, 0)); + move(tool, h.ctx, pointer(100, 0)); + up(tool, h.ctx, pointer(100, 0)); + + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toHaveLength(0); + expect(h.previewOf()).toBeNull(); + }); + + it('is a no-op when the selected gradient layer is radial (angle has no visual effect)', () => { + const layer = gradientLayer({ + source: { + angle: 0, + kind: 'radial', + stops: [ + { color: '#000000', offset: 0 }, + { color: '#ffffff', offset: 1 }, + ], + type: 'gradient', + }, + } as Partial); + const doc = makeDoc({ layers: [layer], selectedLayerId: 'grad-existing' }); + const h = createHarness(doc); + const tool = createGradientTool(); + + down(tool, h.ctx, pointer(0, 0)); + move(tool, h.ctx, pointer(100, 0)); + up(tool, h.ctx, pointer(100, 0)); + + // A radial gradient ignores `angle`, so dragging on one would only ever + // produce an angle-only, visually-inert commit. Skipped entirely: no + // commit, no dispatch, no dangling preview. + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toHaveLength(0); + expect(h.previewOf()).toBeNull(); + }); + + it('creates a new gradient when the selected layer is not a gradient', () => { + const paint = gradientLayer({ + id: 'paint-1', + source: { bitmap: null, type: 'paint' }, + } as Partial); + const doc = makeDoc({ layers: [paint], selectedLayerId: 'paint-1' }); + const h = createHarness(doc); + const tool = createGradientTool(); + + down(tool, h.ctx, pointer(0, 0)); + move(tool, h.ctx, pointer(50, 50)); + up(tool, h.ctx, pointer(50, 50)); + + expect(h.commits[0]?.forward.type).toBe('addCanvasLayer'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/gradientTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/gradientTool.ts new file mode 100644 index 00000000000..109ce6201fa --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/gradientTool.ts @@ -0,0 +1,174 @@ +/** + * The gradient tool: drag to set a gradient's ANGLE from the drag vector. + * + * Interaction contract (CANVAS_PLAN Phase 6.1): + * - **Pointer-down** (primary button) starts a gesture at the press point. + * - **Pointer-move** (past a small threshold) updates a transient overlay + * preview (`stores.gradientPreview`, the drag vector) — it never dispatches. + * - **Commit** (pointer-up after a real drag): exactly ONE commit. + * - A gradient layer is selected (unlocked + visible) → one `commitStructural` + * with `updateCanvasLayerSource` (new/old angle; kind + stops preserved). + * - Otherwise → create a new bbox-sized gradient layer (angle from the drag, + * kind + stops from the tool options, extent = the generation frame) via + * `addCanvasLayer`. + * - A selected gradient layer that is locked/hidden is a no-op (don't silently + * spawn a new layer over it), mirroring the paint tool's locked-target rule. + * - **Cancel** (Esc / pointercancel): drops the preview, no dispatch. + * + * Gradient layers are content-sized via the contract's explicit `width`/`height` + * extent — set at creation (bbox-sized) and preserved across angle edits — so + * only the ANGLE is derived from the drag, not a bounding box. The selection + * mask does NOT constrain gradient layers (parametric, not pixels). + * + * Zero React, zero import-time side effects. + */ + +import type { Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasLayerSourceContract, CanvasRasterLayerContractV2 } from '@workbench/types'; + +import type { Tool, ToolContext } from './tool'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** Screen-space distance (CSS px) the pointer must travel before a press becomes a drag. */ +export const GRADIENT_DRAG_THRESHOLD_PX = 3; + +interface GestureState { + startDoc: Vec2; + startScreen: Vec2; + moved: boolean; +} + +/** Degrees of the vector from `start` to `end` (0° = left→right). */ +export const angleFromDrag = (start: Vec2, end: Vec2): number => + (Math.atan2(end.y - start.y, end.x - start.x) * 180) / Math.PI; + +/** Creates a fresh gradient tool with its own gesture state. */ +export const createGradientTool = (): Tool => { + let state: GestureState | null = null; + + const clearPreview = (ctx: ToolContext): void => { + ctx.stores.gradientPreview.set(null); + ctx.invalidate({ overlay: true }); + }; + + return { + cursor: () => 'crosshair', + id: 'gradient', + onDeactivate: (ctx) => { + state = null; + clearPreview(ctx); + }, + onKeyCommand: (ctx, command) => { + if (command === 'cancel' && state) { + state = null; + clearPreview(ctx); + } + }, + onPointerCancel: (ctx) => { + state = null; + clearPreview(ctx); + }, + onPointerDown: (ctx, input) => { + if (state || (input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + if (!ctx.getDocument()) { + return; + } + state = { moved: false, startDoc: input.documentPoint, startScreen: input.screenPoint }; + }, + onPointerMove: (ctx, input) => { + if (!state) { + return; + } + if (!state.moved) { + const dxs = input.screenPoint.x - state.startScreen.x; + const dys = input.screenPoint.y - state.startScreen.y; + if (Math.hypot(dxs, dys) < GRADIENT_DRAG_THRESHOLD_PX) { + return; + } + state.moved = true; + } + ctx.stores.gradientPreview.set({ end: input.documentPoint, start: state.startDoc }); + ctx.invalidate({ overlay: true }); + }, + onPointerUp: (ctx, input) => { + if (!state) { + return; + } + const current = state; + state = null; + + if (!current.moved) { + clearPreview(ctx); + return; + } + + const doc = ctx.getDocument(); + if (!doc) { + clearPreview(ctx); + return; + } + const angle = angleFromDrag(current.startDoc, input.documentPoint); + const selected = doc.selectedLayerId ? doc.layers.find((layer) => layer.id === doc.selectedLayerId) : undefined; + + if (selected && selected.type === 'raster' && selected.source.type === 'gradient') { + // Edit the selected gradient layer — unless it's locked/hidden (no-op). + if (selected.isLocked || !selected.isEnabled) { + clearPreview(ctx); + return; + } + const old = selected.source; + // A radial gradient's rendering ignores `angle` entirely — dragging on + // one would only ever change that inert field, producing a commit with + // zero visual effect and a useless history entry. Skip it. + if (old.kind === 'radial') { + clearPreview(ctx); + return; + } + const forward: CanvasProjectMutation = { + id: selected.id, + source: { ...old, angle }, + type: 'updateCanvasLayerSource', + }; + const inverse: CanvasProjectMutation = { id: selected.id, source: old, type: 'updateCanvasLayerSource' }; + ctx.commitStructural('Edit gradient', forward, inverse); + clearPreview(ctx); + return; + } + + // Create a new bbox-sized gradient layer. The document rect is retired, so + // creation covers the generation frame (bbox): the extent is the bbox size, + // positioned at the bbox origin via the layer transform. Angle-drag edits on + // an existing gradient preserve its extent (the `...old` spread above). + const options = ctx.stores.gradientOptions.get(); + const layerId = ctx.createLayerId(); + const source: CanvasLayerSourceContract = { + angle, + height: doc.bbox.height, + kind: options.kind, + stops: options.stops.map((stop) => ({ ...stop })), + type: 'gradient', + width: doc.bbox.width, + }; + const layer: CanvasRasterLayerContractV2 = { + blendMode: 'normal', + id: layerId, + isEnabled: true, + isLocked: false, + name: `Gradient ${doc.layers.length + 1}`, + opacity: 1, + source, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: doc.bbox.x, y: doc.bbox.y }, + type: 'raster', + }; + const forward: CanvasProjectMutation = { index: 0, layer, type: 'addCanvasLayer' }; + const inverse: CanvasProjectMutation = { ids: [layerId], type: 'removeCanvasLayers' }; + ctx.commitStructural('Add gradient', forward, inverse); + clearPreview(ctx); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/lassoTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/lassoTool.test.ts new file mode 100644 index 00000000000..4940cfa7ccd --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/lassoTool.test.ts @@ -0,0 +1,138 @@ +import type { SelectionCommit } from '@workbench/canvas-engine/selection/selectionState'; +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput } from '@workbench/canvas-engine/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { createLassoTool, lassoOpFor } from '@workbench/canvas-engine/tools/lassoTool'; +import { describe, expect, it, vi } from 'vitest'; + +const pointer = ( + x: number, + y: number, + opts: { shift?: boolean; alt?: boolean; buttons?: number } = {} +): PointerInput => ({ + buttons: opts.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: opts.alt ?? false, ctrl: false, meta: false, shift: opts.shift ?? false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +const createHarness = () => { + const stores = createEngineStores(); + const commits: SelectionCommit[] = []; + const dispatched: WorkbenchAction[] = []; + const ctx = { + commitSelection: (commit: SelectionCommit) => commits.push(commit), + createPath2D: (d?: string) => ({ d }) as unknown as Path2D, + dispatch: (action: WorkbenchAction) => dispatched.push(action), + invalidate: vi.fn(), + stores, + } as unknown as ToolContext; + return { commits, ctx, dispatched, stores }; +}; + +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerMove?.(ctx, i, [i]); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); + +describe('lassoOpFor', () => { + it('maps modifiers to ops, falling back to the persistent mode', () => { + const none = { alt: false, ctrl: false, meta: false, shift: false }; + expect(lassoOpFor(none, 'replace')).toBe('replace'); + expect(lassoOpFor(none, 'add')).toBe('add'); + expect(lassoOpFor({ ...none, shift: true }, 'replace')).toBe('add'); + expect(lassoOpFor({ ...none, alt: true }, 'replace')).toBe('subtract'); + expect(lassoOpFor({ ...none, alt: true, shift: true }, 'replace')).toBe('intersect'); + }); +}); + +describe('lassoTool: drag → commit', () => { + const drag = (tool: Tool, ctx: ToolContext, upOpts: { shift?: boolean; alt?: boolean } = {}): void => { + down(tool, ctx, pointer(0, 0)); + move(tool, ctx, pointer(20, 0)); + move(tool, ctx, pointer(20, 20)); + up(tool, ctx, pointer(0, 20, upOpts)); + }; + + it('commits a replace by default and publishes/clears the live preview', () => { + const tool = createLassoTool(); + const { commits, ctx, stores } = createHarness(); + + down(tool, ctx, pointer(0, 0)); + move(tool, ctx, pointer(20, 0)); + expect(stores.lassoPreview.get()).not.toBeNull(); + + move(tool, ctx, pointer(20, 20)); + up(tool, ctx, pointer(0, 20)); + + expect(commits).toHaveLength(1); + expect(commits[0]!.op).toBe('replace'); + expect(commits[0]!.bounds).toEqual({ height: 20, width: 20, x: 0, y: 0 }); + // Preview cleared on commit. + expect(stores.lassoPreview.get()).toBeNull(); + }); + + it('resolves each modifier op', () => { + for (const [mods, op] of [ + [{ shift: true }, 'add'], + [{ alt: true }, 'subtract'], + [{ shift: true, alt: true }, 'intersect'], + ] as const) { + const tool = createLassoTool(); + const { commits, ctx } = createHarness(); + drag(tool, ctx, mods); + expect(commits[0]!.op).toBe(op); + } + }); + + it('uses the persistent lasso op mode when no modifier is held', () => { + const tool = createLassoTool(); + const { commits, ctx, stores } = createHarness(); + stores.lassoOptions.set({ mode: 'subtract' }); + drag(tool, ctx); + expect(commits[0]!.op).toBe('subtract'); + }); + + it('never dispatches to the reducer (selection is transient)', () => { + const tool = createLassoTool(); + const { ctx, dispatched } = createHarness(); + drag(tool, ctx); + expect(dispatched).toHaveLength(0); + }); +}); + +describe('lassoTool: cancel + guards', () => { + it('Escape/pointercancel drops the in-progress path without committing', () => { + const tool = createLassoTool(); + const { commits, ctx, stores } = createHarness(); + down(tool, ctx, pointer(0, 0)); + move(tool, ctx, pointer(20, 0)); + move(tool, ctx, pointer(20, 20)); + tool.onPointerCancel?.(ctx); + expect(commits).toHaveLength(0); + expect(stores.lassoPreview.get()).toBeNull(); + }); + + it('a click (too few points) commits nothing', () => { + const tool = createLassoTool(); + const { commits, ctx } = createHarness(); + down(tool, ctx, pointer(5, 5)); + up(tool, ctx, pointer(5, 5)); + expect(commits).toHaveLength(0); + }); + + it('decimates points closer than the minimum distance', () => { + const tool = createLassoTool(); + const { ctx, stores } = createHarness(); + down(tool, ctx, pointer(0, 0)); + // These are all within 1px of each other → decimated away. + move(tool, ctx, pointer(0.5, 0)); + move(tool, ctx, pointer(1, 0)); + const preview = stores.lassoPreview.get(); + expect(preview).toHaveLength(1); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/lassoTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/lassoTool.ts new file mode 100644 index 00000000000..48322c750fa --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/lassoTool.ts @@ -0,0 +1,123 @@ +/** + * The lasso tool: freehand pixel-selection. + * + * A primary-button drag accumulates document-space polygon points (coalesced by + * the pipeline, then distance-decimated here like the brush thins its input); a + * live dashed preview of the in-progress path is published to the engine's + * `lassoPreview` store (a transient channel — no dispatch, no reducer traffic). + * Pointer-up closes the path and commits it to the engine's selection through + * {@link ToolContext.commitSelection}, with the boolean op resolved from the + * modifiers (shift = add, alt = subtract, shift+alt = intersect) or, with none + * held, the persistent op mode from `lassoOptions`. Escape / pointercancel drops + * the in-progress path. + * + * Selection edits are transient interaction state: they are NOT dispatches and + * NOT recorded on the engine's undo history this phase (legacy parity — selection + * changes aren't undoable). Zero React, zero import-time side effects. + */ + +import type { PointerInput, SelectionOp, Vec2 } from '@workbench/canvas-engine/types'; + +import { polygonBounds, polygonToSvgPath } from '@workbench/canvas-engine/freehand'; + +import type { Tool, ToolContext } from './tool'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** Minimum document-space gap between stored polygon points (input decimation). */ +export const LASSO_MIN_POINT_DISTANCE = 2; + +/** Fewest distinct points that make a fillable selection polygon. */ +const MIN_POLYGON_POINTS = 3; + +/** Resolves the boolean op for a commit: modifiers win, else the persistent mode. */ +export const lassoOpFor = (modifiers: PointerInput['modifiers'], mode: SelectionOp): SelectionOp => { + if (modifiers.shift && modifiers.alt) { + return 'intersect'; + } + if (modifiers.shift) { + return 'add'; + } + if (modifiers.alt) { + return 'subtract'; + } + return mode; +}; + +const distance = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y); + +/** Creates a fresh lasso tool with its own per-gesture point buffer. */ +export const createLassoTool = (): Tool => { + let points: Vec2[] = []; + let active = false; + + const reset = (): void => { + points = []; + active = false; + }; + + /** Appends a point if it is far enough from the last stored one. */ + const pushPoint = (p: Vec2): void => { + const last = points[points.length - 1]; + if (!last || distance(last, p) >= LASSO_MIN_POINT_DISTANCE) { + points.push({ x: p.x, y: p.y }); + } + }; + + const publishPreview = (ctx: ToolContext): void => { + ctx.stores.lassoPreview.set(points.length > 0 ? points.slice() : null); + ctx.invalidate({ overlay: true }); + }; + + const clearPreview = (ctx: ToolContext): void => { + ctx.stores.lassoPreview.set(null); + ctx.invalidate({ overlay: true }); + }; + + return { + cursor: () => 'crosshair', + id: 'lasso', + onDeactivate: (ctx) => { + reset(); + clearPreview(ctx); + }, + onPointerCancel: (ctx) => { + reset(); + clearPreview(ctx); + }, + onPointerDown: (ctx, input) => { + if (active || (input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + active = true; + points = [{ x: input.documentPoint.x, y: input.documentPoint.y }]; + publishPreview(ctx); + }, + onPointerMove: (ctx, input, batch) => { + if (!active) { + return; + } + for (const sample of batch) { + pushPoint(sample.documentPoint); + } + publishPreview(ctx); + }, + onPointerUp: (ctx, input) => { + if (!active) { + return; + } + pushPoint(input.documentPoint); + const polygon = points.slice(); + reset(); + clearPreview(ctx); + if (polygon.length < MIN_POLYGON_POINTS || !ctx.commitSelection) { + return; + } + const path = ctx.createPath2D(polygonToSvgPath(polygon)); + const bounds = polygonBounds(polygon); + const op = lassoOpFor(input.modifiers, ctx.stores.lassoOptions.get().mode); + ctx.commitSelection({ bounds, op, path }); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveHitTest.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveHitTest.test.ts new file mode 100644 index 00000000000..75403560c83 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveHitTest.test.ts @@ -0,0 +1,348 @@ +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { estimateTextExtent } from '@workbench/canvas-engine/render/rasterizers/textRasterizer'; +import { describe, expect, it } from 'vitest'; + +import { hitTestLayer, hittableLayerSize, layerOutlineCorners, topLayerAt } from './moveHitTest'; + +const shapeLayer = (id: string, width: number, height: number): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { fill: '#ffffff', height, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const gradientLayer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { angle: 0, kind: 'linear', stops: [], type: 'gradient' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const textLayer = (id: string, content: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { + align: 'left', + color: '#000000', + content, + fontFamily: 'sans', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const imageLayer = ( + id: string, + opts: { + x?: number; + y?: number; + scaleX?: number; + scaleY?: number; + rotation?: number; + width?: number; + height?: number; + isEnabled?: boolean; + isLocked?: boolean; + } = {} +): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: opts.isEnabled ?? true, + isLocked: opts.isLocked ?? false, + name: id, + opacity: 1, + source: { image: { height: opts.height ?? 20, imageName: id, width: opts.width ?? 20 }, type: 'image' }, + transform: { + rotation: opts.rotation ?? 0, + scaleX: opts.scaleX ?? 1, + scaleY: opts.scaleY ?? 1, + x: opts.x ?? 0, + y: opts.y ?? 0, + }, + type: 'raster', +}); + +/** A control layer (composite group rank 1) backed by an image source. */ +const controlLayer = (id: string, opts: { width?: number; height?: number } = {}): CanvasLayerContract => ({ + adapter: { + beginEndStepPct: [0, 1], + controlMode: 'balanced', + kind: 'controlnet', + model: null, + weight: 1, + }, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height: opts.height ?? 20, imageName: id, width: opts.width ?? 20 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: false, +}); + +const paintLayer = ( + id: string, + bitmap?: { width: number; height: number; offset?: { x: number; y: number } } +): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: bitmap + ? { + bitmap: { height: bitmap.height, imageName: `${id}-bmp`, width: bitmap.width }, + offset: bitmap.offset, + type: 'paint', + } + : { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const maskLayer = ( + id: string, + bitmap?: { width: number; height: number; offset?: { x: number; y: number } } +): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { + bitmap: bitmap ? { height: bitmap.height, imageName: `${id}-mask`, width: bitmap.width } : null, + fill: { color: '#ff0000', style: 'solid' }, + ...(bitmap?.offset ? { offset: bitmap.offset } : {}), + }, + name: id, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +const doc = (layers: CanvasLayerContract[]): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId: null, + version: 2, + width: 100, +}); + +describe('hittableLayerSize', () => { + it('returns native size for image layers', () => { + expect(hittableLayerSize(imageLayer('a', { width: 30, height: 40 }), doc([]))).toEqual({ height: 40, width: 30 }); + }); + + it('returns null for an EMPTY paint layer (content-sized: no bitmap ⇒ not hit-testable)', () => { + expect(hittableLayerSize(paintLayer('a'), doc([]))).toBeNull(); + }); + + it('returns the persisted bitmap size for a paint layer with content', () => { + expect(hittableLayerSize(paintLayer('a', { height: 40, width: 60 }), doc([]))).toEqual({ height: 40, width: 60 }); + }); + + it('returns null for an EMPTY (bitmap-less) mask, but the bitmap size for a painted mask (masks are movable)', () => { + expect(hittableLayerSize(maskLayer('m'), doc([]))).toBeNull(); + expect(hittableLayerSize(maskLayer('m', { height: 24, width: 32 }), doc([]))).toEqual({ height: 24, width: 32 }); + }); + + it('returns the shape extent for shape layers (param for parametric)', () => { + expect(hittableLayerSize(shapeLayer('s', 30, 40), doc([]))).toEqual({ height: 40, width: 30 }); + }); + + it('returns document size for gradient layers', () => { + expect(hittableLayerSize(gradientLayer('g'), doc([]))).toEqual({ height: 100, width: 100 }); + }); + + it('returns the estimated text extent for text layers', () => { + const layer = textLayer('t', 'hi'); + expect(hittableLayerSize(layer, doc([]))).toEqual( + estimateTextExtent(layer.type === 'raster' && layer.source.type === 'text' ? layer.source : (null as never)) + ); + }); +}); + +describe('hitTestLayer: parametric layers are now hit-testable', () => { + it('hits inside a shape layer and misses outside', () => { + const layer = shapeLayer('s', 30, 40); + const d = doc([layer]); + expect(hitTestLayer(layer, d, { x: 15, y: 20 })).toBe(true); + expect(hitTestLayer(layer, d, { x: 35, y: 20 })).toBe(false); + }); +}); + +describe('hitTestLayer', () => { + it('hits inside the transformed bounds and misses outside', () => { + const layer = imageLayer('a', { x: 10, y: 10, width: 20, height: 20 }); + const d = doc([layer]); + expect(hitTestLayer(layer, d, { x: 15, y: 15 })).toBe(true); + expect(hitTestLayer(layer, d, { x: 9, y: 15 })).toBe(false); + expect(hitTestLayer(layer, d, { x: 31, y: 15 })).toBe(false); + }); + + it('accounts for scale', () => { + // 20x20 image scaled x2 → covers [0,40]x[0,40] in document space. + const layer = imageLayer('a', { scaleX: 2, scaleY: 2, width: 20, height: 20 }); + const d = doc([layer]); + expect(hitTestLayer(layer, d, { x: 39, y: 39 })).toBe(true); + expect(hitTestLayer(layer, d, { x: 41, y: 41 })).toBe(false); + }); + + it('accounts for rotation (90° about origin)', () => { + // Rotate a 20x40 image 90° clockwise about its origin: local (0,0)->(0,0), + // local (0,40) -> document (-40, 0). Document point (-10, 5) maps to a local + // point inside [0,20]x[0,40]. + const layer = imageLayer('a', { rotation: Math.PI / 2, width: 20, height: 40 }); + const d = doc([layer]); + expect(hitTestLayer(layer, d, { x: -10, y: 5 })).toBe(true); + expect(hitTestLayer(layer, d, { x: 10, y: 5 })).toBe(false); + }); + + it('never hits a mask layer (no source bounds)', () => { + const layer = maskLayer('m'); + expect(hitTestLayer(layer, doc([layer]), { x: 5, y: 5 })).toBe(false); + }); +}); + +describe('topLayerAt', () => { + it('returns the top-most (index 0) layer that passes the predicate and contains the point', () => { + const top = imageLayer('top', { width: 50, height: 50 }); + const bottom = imageLayer('bottom', { width: 50, height: 50 }); + const d = doc([top, bottom]); + expect(topLayerAt(d, { x: 10, y: 10 }, () => true)?.id).toBe('top'); + }); + + it('skips layers the predicate rejects (e.g. locked/hidden)', () => { + const top = imageLayer('top', { width: 50, height: 50, isLocked: true }); + const bottom = imageLayer('bottom', { width: 50, height: 50 }); + const d = doc([top, bottom]); + // Drag predicate rejects locked → falls through to the bottom layer. + expect(topLayerAt(d, { x: 10, y: 10 }, (l) => !l.isLocked)?.id).toBe('bottom'); + }); + + it('returns null on empty space', () => { + const layer = imageLayer('a', { x: 0, y: 0, width: 10, height: 10 }); + expect(topLayerAt(doc([layer]), { x: 80, y: 80 }, () => true)).toBeNull(); + }); +}); + +describe('topLayerAt: composite-group ordering (batch finding N1)', () => { + // The compositor draws by group rank (raster < control < regional < inpaint + // mask), NOT by raw array index, so the hit-test must agree: a layer in a higher + // group wins the hit over a lower-group layer that sits EARLIER in the array. + it('a control layer wins the hit over an overlapping raster placed earlier in the array', () => { + const raster = imageLayer('raster'); // global index 0 — would win under naive array order + const control = controlLayer('control'); // index 1, but composites above the raster + const d = doc([raster, control]); + expect(topLayerAt(d, { x: 10, y: 10 }, () => true)?.id).toBe('control'); + }); + + it('an inpaint mask (top group) wins over control and raster below it', () => { + const raster = imageLayer('raster'); + const control = controlLayer('control'); + const mask = maskLayer('mask', { width: 20, height: 20 }); + // Array order deliberately does NOT match composite order. + const d = doc([raster, mask, control]); + expect(topLayerAt(d, { x: 10, y: 10 }, () => true)?.id).toBe('mask'); + }); + + it('within a group, array order still decides (index 0 is top-most within the group)', () => { + const top = controlLayer('top'); + const bottom = controlLayer('bottom'); + const raster = imageLayer('raster'); + const d = doc([top, bottom, raster]); + expect(topLayerAt(d, { x: 10, y: 10 }, () => true)?.id).toBe('top'); + }); + + it('falls through to a lower group when the predicate rejects the higher one', () => { + const raster = imageLayer('raster'); + const control = controlLayer('control'); + const d = doc([raster, control]); + // e.g. move-tool auto-select excluding controls → the raster below is grabbed. + expect(topLayerAt(d, { x: 10, y: 10 }, (l) => l.type === 'raster')?.id).toBe('raster'); + }); +}); + +describe('layerOutlineCorners', () => { + it('returns the four document-space corners of the rendered rect', () => { + const layer = imageLayer('a', { x: 5, y: 5, width: 10, height: 10 }); + const corners = layerOutlineCorners(layer, doc([layer])); + expect(corners).toEqual([ + { x: 5, y: 5 }, + { x: 15, y: 5 }, + { x: 15, y: 15 }, + { x: 5, y: 15 }, + ]); + }); + + it('applies an x/y override without mutating the layer transform', () => { + const layer = imageLayer('a', { x: 5, y: 5, width: 10, height: 10 }); + const corners = layerOutlineCorners(layer, doc([layer]), { x: 25, y: 30 }); + expect(corners?.[0]).toEqual({ x: 25, y: 30 }); + expect(corners?.[2]).toEqual({ x: 35, y: 40 }); + expect(layer.transform.x).toBe(5); + }); + + it('returns null for a mask layer', () => { + const layer = maskLayer('m'); + expect(layerOutlineCorners(layer, doc([layer]))).toBeNull(); + }); +}); + +describe('live cache rect (freshly-painted, not-yet-flushed content)', () => { + it('hitTestLayer misses an empty paint layer with no live rect', () => { + const layer = paintLayer('p'); // bitmap: null → empty persisted content + expect(hitTestLayer(layer, doc([layer]), { x: 30, y: 30 })).toBe(false); + }); + + it('hitTestLayer hits content present only in the live cache rect', () => { + const layer = paintLayer('p'); // persisted content still empty (unflushed stroke) + const liveRect = { height: 40, width: 40, x: 20, y: 20 }; + // Inside the live rect: grabbable even though the contract bitmap is null. + expect(hitTestLayer(layer, doc([layer]), { x: 30, y: 30 }, liveRect)).toBe(true); + // Outside the live rect: still a miss. + expect(hitTestLayer(layer, doc([layer]), { x: 5, y: 5 }, liveRect)).toBe(false); + }); + + it('topLayerAt uses the liveRectOf resolver to grab unflushed content', () => { + const layer = paintLayer('p'); + const d = doc([layer]); + const liveRectOf = (id: string) => (id === 'p' ? { height: 40, width: 40, x: 20, y: 20 } : undefined); + expect(topLayerAt(d, { x: 30, y: 30 }, () => true, liveRectOf)?.id).toBe('p'); + // Without the resolver the empty layer is ungrabbable. + expect(topLayerAt(d, { x: 30, y: 30 }, () => true)).toBeNull(); + }); + + it('unions the live rect with persisted content (both contribute to the hit area)', () => { + const layer = paintLayer('p', { width: 10, height: 10 }); // persisted content [0,0,10,10] + const liveRect = { height: 10, width: 10, x: 40, y: 40 }; // grown region off to the side + // A point only inside the grown live region is now hit-testable. + expect(hitTestLayer(layer, doc([layer]), { x: 45, y: 45 }, liveRect)).toBe(true); + // The original persisted region is still hit-testable. + expect(hitTestLayer(layer, doc([layer]), { x: 5, y: 5 }, liveRect)).toBe(true); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveHitTest.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveHitTest.ts new file mode 100644 index 00000000000..2b11e9c9ffb --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveHitTest.ts @@ -0,0 +1,174 @@ +/** + * Pure geometry for the move tool: layer hit-testing and outline corners. + * + * A layer's raster cache holds unscaled pixels in the layer's LOCAL space; the + * compositor draws it through the layer transform (translate·rotate·scale). To + * hit-test a document-space point we invert that transform and check the point + * against the layer's native `[0,w]×[0,h]` rect — so rotation and scale are + * handled exactly, not via an axis-aligned approximation. + * + * Every renderable layer is hit-testable — image by its native size, paint by + * its content rect (bitmap dims at the persisted offset), the parametric sources + * (shape/gradient/text) by the same content rect the compositor/cache use + * (CANVAS_PLAN Phase 5: "param for parametric"), and MASK layers (inpaint / + * regional) by their alpha bitmap's content rect (legacy allows moving masks). + * An EMPTY layer (a brand-new / cleared paint or mask layer with no pixels) + * returns `null`/`false` rather than throwing, so the move/transform tools can + * scan a mixed stack safely. + * + * Zero React, zero import-time side effects. + */ + +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerBaseContract, CanvasLayerContract } from '@workbench/types'; + +import { + getSourceContentRect, + LAYER_GROUP_COUNT, + layerGroupRank, + renderableSourceOf, +} from '@workbench/canvas-engine/document/sources'; +import { applyToPoint, fromTRS, invert } from '@workbench/canvas-engine/math/mat2d'; +import { isEmpty, union } from '@workbench/canvas-engine/math/rect'; + +type LayerTransform = CanvasLayerBaseContract['transform']; + +/** + * Resolves a layer's LIVE cache rect (layer-local), or `undefined`. Threaded from + * the engine so hit-testing covers freshly-painted content the debounced bitmap + * flush hasn't yet written back to the persisted contract — mirrors how + * `invertMask` unions the live cache rect. A pure caller (no engine) omits it. + */ +export type LiveCacheRectOf = (layerId: string) => Rect | undefined; + +/** + * A hit-testable layer's content rectangle in its LOCAL (untransformed) space — + * including its (possibly off-zero) origin for content-sized paint/mask layers — + * or `null` for a layer with no rasterizable content or an EMPTY layer (no + * pixels). Mask layers hit-test by their alpha bitmap's content rect, so the move + * tool can grab a painted mask (matching legacy). Every source (image/paint/ + * shape/gradient/text) and mask reuses {@link getSourceContentRect} — the exact + * rect the cache/compositor render. + */ +export const hittableLayerRect = ( + layer: CanvasLayerContract, + doc: CanvasDocumentContractV2, + liveRect?: Rect +): Rect | null => { + if (!renderableSourceOf(layer)) { + return null; + } + const content = getSourceContentRect(layer, doc); + // Union the live cache rect (when present): a stroke painted moments ago lives + // in the cache but not yet in the persisted contract (the bitmap flush is + // debounced), so content alone would leave freshly-painted pixels ungrabbable — + // and content is EMPTY for a brand-new paint layer whose first stroke hasn't + // flushed. Mirrors `invertMask`'s live-cache union. + const rect = liveRect && !isEmpty(liveRect) ? (isEmpty(content) ? liveRect : union(content, liveRect)) : content; + if (rect.width <= 0 || rect.height <= 0) { + return null; + } + return rect; +}; + +/** + * The native (unscaled) local pixel size of a hit-testable layer, or `null` when + * not hit-testable. A thin wrapper over {@link hittableLayerRect} for callers + * that only need eligibility / dimensions (not the origin). + */ +export const hittableLayerSize = ( + layer: CanvasLayerContract, + doc: CanvasDocumentContractV2 +): { width: number; height: number } | null => { + const rect = hittableLayerRect(layer, doc); + return rect ? { height: rect.height, width: rect.width } : null; +}; + +/** The layer's local→document affine matrix from its transform. */ +export const layerMatrix = (transform: LayerTransform) => + fromTRS({ x: transform.x, y: transform.y }, transform.rotation, transform.scaleX, transform.scaleY); + +/** True when `point` (document space) falls inside `layer`'s rendered bounds. */ +export const hitTestLayer = ( + layer: CanvasLayerContract, + doc: CanvasDocumentContractV2, + point: Vec2, + liveRect?: Rect +): boolean => { + const rect = hittableLayerRect(layer, doc, liveRect); + if (!rect) { + return false; + } + const inverse = invert(layerMatrix(layer.transform)); + if (!inverse) { + return false; + } + const local = applyToPoint(inverse, point); + return local.x >= rect.x && local.x <= rect.x + rect.width && local.y >= rect.y && local.y <= rect.y + rect.height; +}; + +/** + * The top-most layer that both satisfies `predicate` and contains `point`, or + * `null`. "Top-most" follows the COMPOSITE order, not the raw array order: layers + * are visited by group rank high→low (inpaint mask > regional guidance > control > + * raster — see {@link layerGroupRank}), and WITHIN a group by array order (index 0 + * is top-most within its group). This matches what the compositor draws, so a + * control layer painted visually over a raster wins the hit even when the raster + * sits at a lower global index — keeping the move-tool auto-select and the canvas + * context-menu target consistent with the pixels on screen (batch finding N1). The + * predicate lets callers require visible/unlocked; `liveRectOf` (optional) supplies + * each layer's live cache rect so freshly-painted, not-yet-flushed content is + * hit-testable. + */ +export const topLayerAt = ( + doc: CanvasDocumentContractV2, + point: Vec2, + predicate: (layer: CanvasLayerContract) => boolean, + liveRectOf?: LiveCacheRectOf +): CanvasLayerContract | null => { + for (let rank = LAYER_GROUP_COUNT - 1; rank >= 0; rank--) { + for (const layer of doc.layers) { + if (layerGroupRank(layer) !== rank) { + continue; + } + if (predicate(layer) && hitTestLayer(layer, doc, point, liveRectOf?.(layer.id))) { + return layer; + } + } + } + return null; +}; + +/** + * The four document-space corners of a layer's rendered rectangle (for the move + * overlay outline), or `null` when the layer has no hit-testable bounds. + * `override` shifts/scales/rotates the rect for a live drag preview without + * mutating the layer (fields absent from the override fall back to the committed + * transform; the move tool passes only `x`/`y`). + */ +export const layerOutlineCorners = ( + layer: CanvasLayerContract, + doc: CanvasDocumentContractV2, + override?: { x: number; y: number; scaleX?: number; scaleY?: number; rotation?: number } | null +): Vec2[] | null => { + const rect = hittableLayerRect(layer, doc); + if (!rect) { + return null; + } + const transform: LayerTransform = override + ? { + rotation: override.rotation ?? layer.transform.rotation, + scaleX: override.scaleX ?? layer.transform.scaleX, + scaleY: override.scaleY ?? layer.transform.scaleY, + x: override.x, + y: override.y, + } + : layer.transform; + const matrix = layerMatrix(transform); + return [ + { x: rect.x, y: rect.y }, + { x: rect.x + rect.width, y: rect.y }, + { x: rect.x + rect.width, y: rect.y + rect.height }, + { x: rect.x, y: rect.y + rect.height }, + ].map((corner) => applyToPoint(matrix, corner)); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveTool.test.ts new file mode 100644 index 00000000000..c481a301802 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveTool.test.ts @@ -0,0 +1,324 @@ +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it, vi } from 'vitest'; + +import { constrainDelta, createMoveTool } from './moveTool'; + +const imageLayer = ( + id: string, + opts: { x?: number; y?: number; width?: number; height?: number; isLocked?: boolean; isEnabled?: boolean } = {} +): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: opts.isEnabled ?? true, + isLocked: opts.isLocked ?? false, + name: id, + opacity: 1, + source: { image: { height: opts.height ?? 40, imageName: id, width: opts.width ?? 40 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: opts.x ?? 0, y: opts.y ?? 0 }, + type: 'raster', +}); + +const shapeLayer = ( + id: string, + opts: { x?: number; y?: number; width?: number; height?: number } = {} +): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { + fill: '#ffffff', + height: opts.height ?? 40, + kind: 'rect', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: opts.width ?? 40, + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: opts.x ?? 0, y: opts.y ?? 0 }, + type: 'raster', +}); + +const makeDoc = (layers: CanvasLayerContract[], selectedLayerId: string | null): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId, + version: 2, + width: 100, +}); + +const pointer = (x: number, y: number, opts: { shift?: boolean; buttons?: number } = {}): PointerInput => ({ + buttons: opts.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: false, ctrl: false, meta: false, shift: opts.shift ?? false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +interface StructuralCommit { + label: string; + forward: CanvasProjectMutation; + inverse: CanvasProjectMutation; +} + +interface Harness { + ctx: ToolContext; + dispatched: CanvasProjectMutation[]; + commits: StructuralCommit[]; + overrides: { layerId: string; override: { x: number; y: number } | null }[]; +} + +const createHarness = (doc: CanvasDocumentContractV2): Harness => { + const dispatched: CanvasProjectMutation[] = []; + const commits: StructuralCommit[] = []; + const overrides: { layerId: string; override: { x: number; y: number } | null }[] = []; + const ctx: ToolContext = { + backend: null as never, + commitStructural: (label, forward, inverse) => commits.push({ forward, inverse, label }), + createLayerId: () => 'x', + createPath2D: (d) => ({ d }) as unknown as Path2D, + dispatch: (action) => dispatched.push(action), + emitStrokeCommitted: vi.fn(), + getDocument: () => doc, + invalidate: vi.fn(), + // A real (empty) layer cache so the move tool's live-cache-rect hit-test seam + // resolves; no test here relies on cached content, so entries stay absent. + layers: createLayerCacheStore(createTestStubRasterBackend()), + notifyLayerPainted: vi.fn(), + setLayerTransformOverride: (layerId, override) => overrides.push({ layerId, override }), + setOverlayCursor: vi.fn(), + stores: createEngineStores(), + updateCursor: vi.fn(), + viewport: null as never, + }; + return { commits, ctx, dispatched, overrides }; +}; + +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerMove?.(ctx, i, [i]); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); +const cancel = (t: Tool, ctx: ToolContext): void => t.onPointerCancel?.(ctx); + +describe('constrainDelta', () => { + it('passes the raw delta through without shift', () => { + expect(constrainDelta(3, 7, false)).toEqual({ x: 3, y: 7 }); + }); + it('zeroes the smaller axis under shift', () => { + expect(constrainDelta(10, 3, true)).toEqual({ x: 10, y: 0 }); + expect(constrainDelta(2, -8, true)).toEqual({ x: 0, y: -8 }); + }); +}); + +describe('move tool: click selection', () => { + it('selects the top-most visible layer under the point (one dispatch, no commit)', () => { + const doc = makeDoc( + [imageLayer('top', { width: 50, height: 50 }), imageLayer('bottom', { width: 50, height: 50 })], + null + ); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + up(tool, h.ctx, pointer(10, 10)); + + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toEqual([{ id: 'top', type: 'setCanvasSelectedLayer' }]); + }); + + it('clears the selection when clicking empty space', () => { + const doc = makeDoc([imageLayer('a', { width: 10, height: 10 })], 'a'); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(80, 80)); + up(tool, h.ctx, pointer(80, 80)); + + expect(h.dispatched).toEqual([{ id: null, type: 'setCanvasSelectedLayer' }]); + }); + + it('can click-select a locked layer', () => { + const doc = makeDoc([imageLayer('locked', { width: 50, height: 50, isLocked: true })], null); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + up(tool, h.ctx, pointer(10, 10)); + + expect(h.dispatched).toEqual([{ id: 'locked', type: 'setCanvasSelectedLayer' }]); + }); + + it('does not click-select a hidden layer', () => { + const doc = makeDoc([imageLayer('hidden', { width: 50, height: 50, isEnabled: false })], null); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + up(tool, h.ctx, pointer(10, 10)); + + // Hidden layer is not hit → empty-space clear. + expect(h.dispatched).toEqual([{ id: null, type: 'setCanvasSelectedLayer' }]); + }); +}); + +describe('move tool: drag', () => { + it('previews via override on move then commits one structural transform on up', () => { + const doc = makeDoc([imageLayer('a', { x: 0, y: 0, width: 50, height: 50 })], 'a'); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(30, 25)); + up(tool, h.ctx, pointer(30, 25)); + + // Preview override applied during the move, cleared on commit. + expect(h.overrides).toEqual([ + { layerId: 'a', override: { x: 20, y: 15 } }, + { layerId: 'a', override: null }, + ]); + // Exactly one structural commit; the target was already selected → no extra dispatch. + expect(h.dispatched).toHaveLength(0); + expect(h.commits).toHaveLength(1); + expect(h.commits[0]!.forward).toEqual({ + id: 'a', + patch: { transform: { x: 20, y: 15 } }, + type: 'updateCanvasLayer', + }); + expect(h.commits[0]!.inverse).toEqual({ + id: 'a', + patch: { transform: { x: 0, y: 0 } }, + type: 'updateCanvasLayer', + }); + }); + + it('drags a parametric shape layer, committing one structural transform', () => { + // Regression: shape/gradient/text layers were not hit-testable, so the move + // tool skipped them (Phase 5 "param for parametric" hit-testing). + const doc = makeDoc([shapeLayer('s', { x: 0, y: 0, width: 50, height: 50 })], 's'); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(30, 25)); + up(tool, h.ctx, pointer(30, 25)); + + expect(h.commits).toHaveLength(1); + expect(h.commits[0]!.forward).toEqual({ + id: 's', + patch: { transform: { x: 20, y: 15 } }, + type: 'updateCanvasLayer', + }); + }); + + it('auto-selects the pressed unlocked layer before committing the move', () => { + const doc = makeDoc( + [imageLayer('top', { width: 50, height: 50 }), imageLayer('bottom', { width: 50, height: 50 })], + 'bottom' + ); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(30, 10)); + up(tool, h.ctx, pointer(30, 10)); + + // Selection switched to the pressed layer, then the move committed on it. + expect(h.dispatched).toEqual([{ id: 'top', type: 'setCanvasSelectedLayer' }]); + expect(h.commits).toHaveLength(1); + expect(h.commits[0]!.forward).toMatchObject({ id: 'top' }); + }); + + it('moves the selected layer when the press lands on empty space', () => { + const doc = makeDoc([imageLayer('a', { x: 0, y: 0, width: 10, height: 10 })], 'a'); + const h = createHarness(doc); + const tool = createMoveTool(); + + // Press at empty (80,80): no layer hit there, but 'a' is the selected movable layer. + down(tool, h.ctx, pointer(80, 80)); + move(tool, h.ctx, pointer(90, 85)); + up(tool, h.ctx, pointer(90, 85)); + + expect(h.commits).toHaveLength(1); + expect(h.commits[0]!.forward).toEqual({ + id: 'a', + patch: { transform: { x: 10, y: 5 } }, + type: 'updateCanvasLayer', + }); + }); + + it('does not drag a locked layer (auto-select finds nothing, no selected fallback)', () => { + const doc = makeDoc([imageLayer('locked', { width: 50, height: 50, isLocked: true })], null); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(40, 40)); + up(tool, h.ctx, pointer(40, 40)); + + expect(h.commits).toHaveLength(0); + expect(h.overrides).toHaveLength(0); + expect(h.dispatched).toHaveLength(0); + }); + + it('constrains to the dominant axis under shift', () => { + const doc = makeDoc([imageLayer('a', { x: 0, y: 0, width: 50, height: 50 })], 'a'); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(40, 15, { shift: true })); + up(tool, h.ctx, pointer(40, 15, { shift: true })); + + expect(h.commits[0]!.forward).toEqual({ + id: 'a', + patch: { transform: { x: 30, y: 0 } }, + type: 'updateCanvasLayer', + }); + }); + + it('commits nothing for a sub-threshold press+release (treated as a click)', () => { + const doc = makeDoc([imageLayer('a', { width: 50, height: 50 })], 'a'); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(11, 11)); // < 3px screen + up(tool, h.ctx, pointer(11, 11)); + + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toEqual([{ id: 'a', type: 'setCanvasSelectedLayer' }]); + }); +}); + +describe('move tool: cancel', () => { + it('drops the override and commits nothing on Esc mid-drag', () => { + const doc = makeDoc([imageLayer('a', { width: 50, height: 50 })], 'a'); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(40, 40)); + cancel(tool, h.ctx); + + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toHaveLength(0); + // Last override op clears the preview. + expect(h.overrides.at(-1)).toEqual({ layerId: 'a', override: null }); + + // A subsequent up (from the released pointer) does nothing. + up(tool, h.ctx, pointer(40, 40)); + expect(h.commits).toHaveLength(0); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveTool.ts new file mode 100644 index 00000000000..4fdd5514667 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveTool.ts @@ -0,0 +1,208 @@ +/** + * The move tool: click to select the top-most layer under the cursor, drag to + * move a layer (auto-selecting the pressed unlocked layer, photo-editor style). + * + * Interaction contract (see CANVAS_PLAN Phase 3): + * - **Click** (press+release under the drag threshold): selects the top-most + * VISIBLE layer whose rendered bounds contain the point (locked layers may be + * click-selected); empty space clears the selection. One `setCanvasSelectedLayer` + * dispatch, no history entry. + * - **Drag**: moves a layer. The pressed point's top-most enabled+unlocked layer + * becomes the target (auto-select); otherwise the currently-selected + * enabled+unlocked layer is moved. Hidden/locked layers are never dragged. + * `shift` constrains motion to the dominant axis. Pointer-move only sets a + * transient transform override (live preview) — it never dispatches. + * - **Commit** (pointer-up after a real move): one `commitStructural` with the + * new/old transform x/y (plus a selection dispatch when the target wasn't + * already selected). A zero-delta drag commits nothing. + * - **Cancel** (Esc / pointercancel): drops the override, no dispatch. + * + * Zero React, zero import-time side effects. + */ + +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasLayerContract } from '@workbench/types'; + +import type { Tool, ToolContext } from './tool'; + +import { type LiveCacheRectOf, topLayerAt } from './moveHitTest'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** + * A live-cache-rect resolver over the tool's layer cache, so hit-testing grabs + * freshly-painted content the debounced bitmap flush hasn't yet written back to + * the persisted contract (a new layer + stroke is otherwise ungrabbable until the + * ~1.5s flush lands). + */ +const liveRectOf = + (ctx: ToolContext): LiveCacheRectOf => + (layerId) => + ctx.layers.get(layerId)?.rect; + +/** Screen-space distance (CSS px) the pointer must travel before a press becomes a drag. */ +export const MOVE_DRAG_THRESHOLD_PX = 3; + +const isVisible = (layer: CanvasLayerContract): boolean => layer.isEnabled; +const isDraggable = (layer: CanvasLayerContract): boolean => layer.isEnabled && !layer.isLocked; + +/** Applies the shift-to-dominant-axis constraint to a document-space delta. */ +export const constrainDelta = (dx: number, dy: number, shift: boolean): Vec2 => { + if (!shift) { + return { x: dx, y: dy }; + } + return Math.abs(dx) >= Math.abs(dy) ? { x: dx, y: 0 } : { x: 0, y: dy }; +}; + +interface GestureState { + startDoc: Vec2; + startScreen: Vec2; + /** The layer being dragged (null when the press had nothing to move). */ + targetId: string | null; + /** The drag target's original transform x/y. */ + origin: { x: number; y: number } | null; + /** The top-most visible layer under the press, for click selection (lock allowed). */ + clickTargetId: string | null; + /** The document's selected layer at press time. */ + selectedAtStart: string | null; + moved: boolean; +} + +/** Creates a fresh move tool with its own gesture state. */ +export const createMoveTool = (): Tool => { + let state: GestureState | null = null; + + const clearOverride = (ctx: ToolContext): void => { + if (state?.targetId) { + ctx.setLayerTransformOverride(state.targetId, null); + } + }; + + const endGesture = (): void => { + state = null; + }; + + const resolveDragTarget = (ctx: ToolContext, point: Vec2, selectedId: string | null): CanvasLayerContract | null => { + const doc = ctx.getDocument(); + if (!doc) { + return null; + } + // Auto-select: the pressed point's top-most enabled+unlocked layer wins. + const hit = topLayerAt(doc, point, isDraggable, liveRectOf(ctx)); + if (hit) { + return hit; + } + // Otherwise fall back to moving the currently-selected layer (if movable). + const selected = selectedId ? doc.layers.find((layer) => layer.id === selectedId) : undefined; + return selected && isDraggable(selected) ? selected : null; + }; + + const previewAt = (ctx: ToolContext, input: PointerInput): void => { + if (!state || !state.targetId || !state.origin) { + return; + } + const delta = constrainDelta( + input.documentPoint.x - state.startDoc.x, + input.documentPoint.y - state.startDoc.y, + input.modifiers.shift + ); + ctx.setLayerTransformOverride(state.targetId, { x: state.origin.x + delta.x, y: state.origin.y + delta.y }); + }; + + return { + cursor: () => 'move', + id: 'move', + onDeactivate: (ctx) => { + clearOverride(ctx); + endGesture(); + }, + onPointerCancel: (ctx) => { + clearOverride(ctx); + ctx.invalidate({ overlay: true }); + endGesture(); + }, + onPointerDown: (ctx, input) => { + if (state || (input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + const doc = ctx.getDocument(); + if (!doc) { + return; + } + const clickTarget = topLayerAt(doc, input.documentPoint, isVisible, liveRectOf(ctx)); + const dragTarget = resolveDragTarget(ctx, input.documentPoint, doc.selectedLayerId); + state = { + clickTargetId: clickTarget?.id ?? null, + moved: false, + origin: dragTarget ? { x: dragTarget.transform.x, y: dragTarget.transform.y } : null, + selectedAtStart: doc.selectedLayerId, + startDoc: input.documentPoint, + startScreen: input.screenPoint, + targetId: dragTarget?.id ?? null, + }; + }, + onPointerMove: (ctx, input) => { + if (!state) { + return; + } + if (!state.moved) { + const dxs = input.screenPoint.x - state.startScreen.x; + const dys = input.screenPoint.y - state.startScreen.y; + if (Math.hypot(dxs, dys) < MOVE_DRAG_THRESHOLD_PX) { + return; + } + state.moved = true; + } + previewAt(ctx, input); + }, + onPointerUp: (ctx, input) => { + if (!state) { + return; + } + const current = state; + endGesture(); + + if (!current.moved) { + // A click: select the top-most visible layer, or clear on empty space. + ctx.dispatch({ id: current.clickTargetId, type: 'setCanvasSelectedLayer' }); + return; + } + + if (!current.targetId || !current.origin) { + // Dragged over empty space with nothing to move — no-op. + return; + } + + const delta = constrainDelta( + input.documentPoint.x - current.startDoc.x, + input.documentPoint.y - current.startDoc.y, + input.modifiers.shift + ); + const next = { x: current.origin.x + delta.x, y: current.origin.y + delta.y }; + + if (next.x === current.origin.x && next.y === current.origin.y) { + // Zero-delta drag: behave like a click-select of the target, no commit. + ctx.setLayerTransformOverride(current.targetId, null); + ctx.dispatch({ id: current.targetId, type: 'setCanvasSelectedLayer' }); + return; + } + + // Auto-select the dragged layer if it wasn't already selected (no history). + if (current.targetId !== current.selectedAtStart) { + ctx.dispatch({ id: current.targetId, type: 'setCanvasSelectedLayer' }); + } + ctx.commitStructural( + 'Move layer', + { id: current.targetId, patch: { transform: { x: next.x, y: next.y } }, type: 'updateCanvasLayer' }, + { + id: current.targetId, + patch: { transform: { x: current.origin.x, y: current.origin.y } }, + type: 'updateCanvasLayer', + } + ); + // The committed transform now flows through the mirror; drop the preview. + ctx.setLayerTransformOverride(current.targetId, null); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintConstants.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintConstants.test.ts new file mode 100644 index 00000000000..42177978f5b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintConstants.test.ts @@ -0,0 +1,44 @@ +import { MAX_BRUSH_SIZE, MIN_BRUSH_SIZE } from '@workbench/canvas-engine/engineStores'; +import { describe, expect, it } from 'vitest'; + +import { clampBrushSize, SIZE_STEP_FACTOR, stepBrushSize } from './paintConstants'; + +describe('clampBrushSize', () => { + it('clamps to [MIN_BRUSH_SIZE, MAX_BRUSH_SIZE]', () => { + expect(clampBrushSize(0)).toBe(MIN_BRUSH_SIZE); + expect(clampBrushSize(-100)).toBe(MIN_BRUSH_SIZE); + expect(clampBrushSize(1_000_000)).toBe(MAX_BRUSH_SIZE); + }); + + it('rounds to the nearest integer', () => { + expect(clampBrushSize(50.6)).toBe(51); + expect(clampBrushSize(50.4)).toBe(50); + }); +}); + +describe('stepBrushSize', () => { + it('grows by SIZE_STEP_FACTOR for direction +1', () => { + expect(stepBrushSize(50, 1)).toBe(Math.round(50 * (1 + SIZE_STEP_FACTOR))); + }); + + it('shrinks by SIZE_STEP_FACTOR for direction -1', () => { + expect(stepBrushSize(50, -1)).toBe(Math.round(50 * (1 - SIZE_STEP_FACTOR))); + }); + + it('clamps growth at MAX_BRUSH_SIZE', () => { + expect(stepBrushSize(MAX_BRUSH_SIZE, 1)).toBe(MAX_BRUSH_SIZE); + }); + + it('clamps shrink at MIN_BRUSH_SIZE', () => { + expect(stepBrushSize(MIN_BRUSH_SIZE, -1)).toBe(MIN_BRUSH_SIZE); + }); + + it('is monotonic: repeated growth never decreases size', () => { + let size = MIN_BRUSH_SIZE; + for (let i = 0; i < 20; i++) { + const next = stepBrushSize(size, 1); + expect(next).toBeGreaterThanOrEqual(size); + size = next; + } + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintConstants.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintConstants.ts new file mode 100644 index 00000000000..1989ef363a6 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintConstants.ts @@ -0,0 +1,24 @@ +/** + * Shared brush/eraser painting constants and the size-step math both the + * ctrl+wheel path (`input/wheel.ts`) and the `[`/`]` hotkeys drive through the + * same helper. Zero React, zero import-time side effects. + */ + +import { MAX_BRUSH_SIZE, MIN_BRUSH_SIZE } from '@workbench/canvas-engine/engineStores'; + +/** Freehand `thinning` applied when brush pressure sensitivity is on. */ +export const PRESSURE_THINNING = 0.5; + +/** Fraction to grow/shrink the brush/eraser diameter per size-step notch (ctrl+wheel or `[`/`]`). */ +export const SIZE_STEP_FACTOR = 0.1; + +/** Clamps a brush/eraser diameter to `[MIN_BRUSH_SIZE, MAX_BRUSH_SIZE]`, rounded to the nearest integer. */ +export const clampBrushSize = (value: number): number => + Math.max(MIN_BRUSH_SIZE, Math.min(MAX_BRUSH_SIZE, Math.round(value))); + +/** + * Steps a brush/eraser diameter by one notch: `+1` grows by `SIZE_STEP_FACTOR`, + * `-1` shrinks by it, clamped to the valid size range. + */ +export const stepBrushSize = (size: number, direction: 1 | -1): number => + clampBrushSize(size * (1 + direction * SIZE_STEP_FACTOR)); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.test.ts new file mode 100644 index 00000000000..03d6f690396 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.test.ts @@ -0,0 +1,503 @@ +import type { StubRasterBackend, StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { + ControlPixelEditTransaction, + StrokeCommittedEvent, + Tool, + ToolContext, +} from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasControlLayerContract, CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { isEmpty } from '@workbench/canvas-engine/math/rect'; +import { createLayerCacheStore, type LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { createBrushTool } from '@workbench/canvas-engine/tools/brushTool'; +import { createEraserTool } from '@workbench/canvas-engine/tools/eraserTool'; +import { describe, expect, it, vi } from 'vitest'; + +const paintLayer = ( + id: string, + opts: { isLocked?: boolean; isEnabled?: boolean; isTransparencyLocked?: boolean } = {} +): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: opts.isEnabled ?? true, + isLocked: opts.isLocked ?? false, + ...(opts.isTransparencyLocked ? { isTransparencyLocked: true } : {}), + name: id, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const imageLayer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height: 10, imageName: id, width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const inpaintMaskLayer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + name: id, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +const controlPaintLayer = (id: string): CanvasControlLayerContract => ({ + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: true, +}); + +const controlImageLayer = (id: string): CanvasControlLayerContract => ({ + ...controlPaintLayer(id), + source: { image: { height: 20, imageName: id, width: 20 }, type: 'image' }, +}); + +const controlTransaction = (): ControlPixelEditTransaction => ({ + cancel: vi.fn(), + commitPatch: vi.fn(), + commitStroke: vi.fn(), + layerId: 'control', +}); + +const makeDoc = (layers: CanvasLayerContract[], selectedLayerId: string | null): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId, + version: 2, + width: 100, +}); + +const pointer = (x: number, y: number, opts: { pressure?: number; buttons?: number } = {}): PointerInput => ({ + buttons: opts.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: false, ctrl: false, meta: false, shift: false }, + pointerType: 'mouse', + pressure: opts.pressure ?? 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +// Optional-method drivers, so tests read as gestures rather than `?.` noise. +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput, batch: PointerInput[]): void => + t.onPointerMove?.(ctx, i, batch); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); +const cancel = (t: Tool, ctx: ToolContext): void => t.onPointerCancel?.(ctx); + +interface Harness { + beginControlPixelEdit: ReturnType | null; + ctx: ToolContext; + backend: StubRasterBackend; + layers: LayerCacheStore; + dispatched: CanvasProjectMutation[]; + strokes: StrokeCommittedEvent[]; + painted: string[]; + createdIds: string[]; +} + +const createHarness = ( + doc: CanvasDocumentContractV2, + transaction: ControlPixelEditTransaction | null | undefined = undefined +): Harness => { + const backend = createTestStubRasterBackend(); + const layers = createLayerCacheStore(backend); + const stores = createEngineStores(); + const dispatched: CanvasProjectMutation[] = []; + const strokes: StrokeCommittedEvent[] = []; + const painted: string[] = []; + const createdIds: string[] = []; + const beginControlPixelEdit = transaction === undefined ? null : vi.fn(() => transaction); + let idCounter = 0; + + const ctx: ToolContext = { + backend, + ...(beginControlPixelEdit ? { beginControlPixelEdit } : {}), + commitStructural: vi.fn(), + createLayerId: () => { + const id = `new-layer-${(idCounter += 1)}`; + createdIds.push(id); + return id; + }, + createPath2D: (d) => ({ d }) as unknown as Path2D, + dispatch: (action) => dispatched.push(action), + emitStrokeCommitted: (event) => { + painted.push(event.layerId); + strokes.push(event); + }, + getDocument: () => doc, + invalidate: vi.fn(), + layers, + notifyLayerPainted: (layerId) => painted.push(layerId), + setLayerTransformOverride: vi.fn(), + setOverlayCursor: vi.fn(), + stores, + updateCursor: vi.fn(), + viewport: null as never, + }; + + return { backend, beginControlPixelEdit, createdIds, ctx, dispatched, layers, painted, strokes }; +}; + +const cacheOps = (surface: StubRasterSurface): string[] => surface.callLog.map((entry) => entry.op); + +/** The value of the last `globalCompositeOperation` assignment recorded on the cache surface. */ +const lastCompositeOp = (surface: StubRasterSurface): unknown => + surface.callLog + .filter((entry) => entry.op === 'set' && entry.args[0] === 'globalCompositeOperation') + .map((entry) => entry.args[1]) + .pop(); + +/** The value of the last `globalAlpha` assignment recorded on the cache surface. */ +const lastGlobalAlpha = (surface: StubRasterSurface): unknown => + surface.callLog + .filter((entry) => entry.op === 'set' && entry.args[0] === 'globalAlpha') + .map((entry) => entry.args[1]) + .pop(); + +const cacheSurface = (h: Harness, layerId: string): StubRasterSurface => + h.layers.get(layerId)!.surface as StubRasterSurface; + +describe('brush tool: stroke into an existing paint layer', () => { + it('paints, emits one strokeCommitted with a content-sized dirty rect, and does not dispatch', () => { + const doc = makeDoc([paintLayer('paint1')], 'paint1'); + const h = createHarness(doc); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + move(brush, h.ctx, pointer(40, 40), [pointer(30, 30), pointer(40, 40)]); + up(brush, h.ctx, pointer(40, 40)); + + expect(h.dispatched).toHaveLength(0); + expect(h.strokes).toHaveLength(1); + const event = h.strokes[0]!; + expect(event.tool).toBe('brush'); + expect(event.layerId).toBe('paint1'); + + // Dirty rect is integer and non-empty. Content-sized: it is the stroke's TRUE + // bounds (no document clamp), so it can extend past the document edges — a + // paint layer grows with its strokes. + const { dirtyRect } = event; + expect(Number.isInteger(dirtyRect.x)).toBe(true); + expect(Number.isInteger(dirtyRect.width)).toBe(true); + expect(isEmpty(dirtyRect)).toBe(false); + + // Before/after snapshots are sized exactly to the dirty rect. + expect(event.beforeImageData.width).toBe(dirtyRect.width); + expect(event.beforeImageData.height).toBe(dirtyRect.height); + expect(event.afterImageData.width).toBe(dirtyRect.width); + expect(event.afterImageData.height).toBe(dirtyRect.height); + + // The layer version was bumped exactly once (on commit). + expect(h.painted).toEqual(['paint1']); + + // The cache surface received the buffered-stroke composite (source-over). + const ops = cacheOps(cacheSurface(h, 'paint1')); + expect(ops).toContain('getImageData'); + expect(ops).toContain('drawImage'); + expect(lastCompositeOp(cacheSurface(h, 'paint1'))).toBe('source-over'); + }); +}); + +describe('eraser tool', () => { + it('composites the stroke with destination-out and reports tool "eraser"', () => { + const doc = makeDoc([paintLayer('paint1')], 'paint1'); + const h = createHarness(doc); + const eraser = createEraserTool(); + + down(eraser, h.ctx, pointer(20, 20)); + move(eraser, h.ctx, pointer(40, 40), [pointer(40, 40)]); + up(eraser, h.ctx, pointer(40, 40)); + + expect(h.strokes).toHaveLength(1); + expect(h.strokes[0]!.tool).toBe('eraser'); + expect(lastCompositeOp(cacheSurface(h, 'paint1'))).toBe('destination-out'); + }); +}); + +describe('brush tool: cancel', () => { + it('restores pixels via putImageData and emits no event', () => { + const doc = makeDoc([paintLayer('paint1')], 'paint1'); + const h = createHarness(doc); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + move(brush, h.ctx, pointer(40, 40), [pointer(40, 40)]); + cancel(brush, h.ctx); + + expect(h.strokes).toHaveLength(0); + expect(h.painted).toHaveLength(0); + expect(cacheOps(cacheSurface(h, 'paint1'))).toContain('putImageData'); + }); +}); + +describe('paint tool: target resolution', () => { + it('commits a selected control stroke through its transaction without adding a raster layer', () => { + const transaction = controlTransaction(); + const h = createHarness(makeDoc([controlPaintLayer('control')], 'control'), transaction); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(10, 10)); + move(brush, h.ctx, pointer(20, 20), [pointer(20, 20)]); + up(brush, h.ctx, pointer(20, 20, { buttons: 0 })); + + expect(h.beginControlPixelEdit).toHaveBeenCalledWith('control'); + expect(transaction.commitStroke).toHaveBeenCalledOnce(); + expect(transaction.cancel).not.toHaveBeenCalled(); + expect(h.dispatched).toHaveLength(0); + }); + + it.each(['pointercancel', 'deactivate'] as const)('rolls back control preparation on %s', (ending) => { + const transaction = controlTransaction(); + const h = createHarness(makeDoc([controlImageLayer('control')], 'control'), transaction); + const brush = createBrushTool(); + down(brush, h.ctx, pointer(10, 10)); + if (ending === 'pointercancel') { + cancel(brush, h.ctx); + } else { + brush.onDeactivate?.(h.ctx); + } + expect(transaction.cancel).toHaveBeenCalledOnce(); + expect(transaction.commitStroke).not.toHaveBeenCalled(); + }); + + it('aborts the control transaction and releases the gesture after pointer-move painting fails', () => { + const transaction = controlTransaction(); + const h = createHarness(makeDoc([controlPaintLayer('control')], 'control'), transaction); + const brush = createBrushTool(); + down(brush, h.ctx, pointer(10, 10)); + const drawImage = vi.spyOn(cacheSurface(h, 'control').ctx, 'drawImage').mockImplementation(() => { + throw new Error('move paint failed'); + }); + + expect(() => move(brush, h.ctx, pointer(20, 20), [pointer(20, 20)])).toThrow('move paint failed'); + + expect(transaction.cancel).toHaveBeenCalledOnce(); + expect(transaction.commitStroke).not.toHaveBeenCalled(); + expect(h.dispatched).toHaveLength(0); + expect(h.painted).toHaveLength(0); + expect(h.strokes).toHaveLength(0); + + drawImage.mockRestore(); + down(brush, h.ctx, pointer(30, 30)); + expect(h.beginControlPixelEdit).toHaveBeenCalledTimes(2); + }); + + it('aborts the control transaction and releases the gesture after stroke finalization fails', () => { + const transaction = controlTransaction(); + const h = createHarness(makeDoc([controlPaintLayer('control')], 'control'), transaction); + const brush = createBrushTool(); + down(brush, h.ctx, pointer(10, 10)); + const getImageData = vi.spyOn(cacheSurface(h, 'control').ctx, 'getImageData').mockImplementation(() => { + throw new Error('stroke finalization failed'); + }); + + expect(() => up(brush, h.ctx, pointer(10, 10, { buttons: 0 }))).toThrow('stroke finalization failed'); + + expect(transaction.cancel).toHaveBeenCalledOnce(); + expect(transaction.commitStroke).not.toHaveBeenCalled(); + expect(h.dispatched).toHaveLength(0); + expect(h.painted).toHaveLength(0); + expect(h.strokes).toHaveLength(0); + + getImageData.mockRestore(); + down(brush, h.ctx, pointer(30, 30)); + expect(h.beginControlPixelEdit).toHaveBeenCalledTimes(2); + }); + + it('still cancels the control transaction and releases the gesture when pixel restoration fails', () => { + const transaction = controlTransaction(); + const h = createHarness(makeDoc([controlPaintLayer('control')], 'control'), transaction); + const brush = createBrushTool(); + down(brush, h.ctx, pointer(10, 10)); + const putImageData = vi.spyOn(cacheSurface(h, 'control').ctx, 'putImageData').mockImplementation(() => { + throw new Error('pixel restoration failed'); + }); + + expect(() => cancel(brush, h.ctx)).toThrow('pixel restoration failed'); + + expect(transaction.cancel).toHaveBeenCalledOnce(); + expect(transaction.commitStroke).not.toHaveBeenCalled(); + expect(h.dispatched).toHaveLength(0); + expect(h.painted).toHaveLength(0); + expect(h.strokes).toHaveLength(0); + + putImageData.mockRestore(); + down(brush, h.ctx, pointer(30, 30)); + expect(h.beginControlPixelEdit).toHaveBeenCalledTimes(2); + }); + + it('does not roll back an accepted stroke when transaction publication throws', () => { + const transaction = controlTransaction(); + vi.mocked(transaction.commitStroke).mockImplementation(() => { + throw new Error('listener publication failed'); + }); + const h = createHarness(makeDoc([controlPaintLayer('control')], 'control'), transaction); + const brush = createBrushTool(); + down(brush, h.ctx, pointer(10, 10)); + + expect(() => up(brush, h.ctx, pointer(10, 10, { buttons: 0 }))).toThrow('listener publication failed'); + + expect(transaction.commitStroke).toHaveBeenCalledOnce(); + expect(transaction.cancel).not.toHaveBeenCalled(); + down(brush, h.ctx, pointer(30, 30)); + expect(h.beginControlPixelEdit).toHaveBeenCalledTimes(2); + }); + + it('does not fall through to raster auto-create when control preparation is rejected', () => { + const h = createHarness(makeDoc([controlImageLayer('control')], 'control'), null); + const brush = createBrushTool(); + down(brush, h.ctx, pointer(10, 10)); + expect(h.dispatched).toHaveLength(0); + }); + + it('auto-creates a paint layer (one dispatch) when the selection is not paintable', () => { + const doc = makeDoc([imageLayer('img1')], 'img1'); + const h = createHarness(doc); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + up(brush, h.ctx, pointer(20, 20)); + + expect(h.dispatched).toHaveLength(1); + const action = h.dispatched[0]!; + expect(action.type).toBe('addCanvasLayer'); + if (action.type === 'addCanvasLayer' && action.layer.type === 'raster') { + expect(action.layer.source.type).toBe('paint'); + expect(action.layer.id).toBe(h.createdIds[0]); + } else { + throw new Error('expected an addCanvasLayer with a raster layer'); + } + // The stroke commits against the freshly-created layer. + expect(h.strokes).toHaveLength(1); + expect(h.strokes[0]!.layerId).toBe(h.createdIds[0]); + }); + + it('auto-creates when nothing is selected', () => { + const doc = makeDoc([paintLayer('paint1')], null); + const h = createHarness(doc); + const brush = createBrushTool(); + down(brush, h.ctx, pointer(20, 20)); + expect(h.dispatched).toHaveLength(1); + expect(h.dispatched[0]!.type).toBe('addCanvasLayer'); + }); + + it('no-ops on a locked selected paint layer (no dispatch, no paint)', () => { + const doc = makeDoc([paintLayer('paint1', { isLocked: true })], 'paint1'); + const h = createHarness(doc); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + move(brush, h.ctx, pointer(40, 40), [pointer(40, 40)]); + up(brush, h.ctx, pointer(40, 40)); + + expect(h.dispatched).toHaveLength(0); + expect(h.strokes).toHaveLength(0); + }); + + it('no-ops on a disabled selected paint layer', () => { + const doc = makeDoc([paintLayer('paint1', { isEnabled: false })], 'paint1'); + const h = createHarness(doc); + const brush = createBrushTool(); + down(brush, h.ctx, pointer(20, 20)); + up(brush, h.ctx, pointer(20, 20)); + expect(h.strokes).toHaveLength(0); + }); +}); + +describe('transparency lock', () => { + it('brush composites source-atop on a transparency-locked layer (colour only on existing pixels)', () => { + const doc = makeDoc([paintLayer('paint1', { isTransparencyLocked: true })], 'paint1'); + const h = createHarness(doc); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + move(brush, h.ctx, pointer(40, 40), [pointer(40, 40)]); + up(brush, h.ctx, pointer(40, 40)); + + expect(h.strokes).toHaveLength(1); + expect(lastCompositeOp(cacheSurface(h, 'paint1'))).toBe('source-atop'); + }); + + it('refuses the eraser entirely on a transparency-locked layer (no stroke, no alpha change)', () => { + const doc = makeDoc([paintLayer('paint1', { isTransparencyLocked: true })], 'paint1'); + const h = createHarness(doc); + const eraser = createEraserTool(); + + down(eraser, h.ctx, pointer(20, 20)); + move(eraser, h.ctx, pointer(40, 40), [pointer(40, 40)]); + up(eraser, h.ctx, pointer(40, 40)); + + expect(h.strokes).toHaveLength(0); + expect(h.painted).toEqual([]); + }); + + it('brush is unaffected (source-over) when transparency is NOT locked', () => { + const doc = makeDoc([paintLayer('paint1')], 'paint1'); + const h = createHarness(doc); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + move(brush, h.ctx, pointer(40, 40), [pointer(40, 40)]); + up(brush, h.ctx, pointer(40, 40)); + + expect(lastCompositeOp(cacheSurface(h, 'paint1'))).toBe('source-over'); + }); +}); + +describe('mask strokes are forced opaque', () => { + it('composites a mask stroke at globalAlpha 1 even when the brush opacity is 0.5', () => { + const doc = makeDoc([inpaintMaskLayer('mask1')], 'mask1'); + const h = createHarness(doc); + // A half-opacity brush: on a raster layer this would land alpha ~128, but a + // mask is an all-or-nothing alpha stencil and must be forced fully opaque. + h.ctx.stores.brushOptions.set({ ...h.ctx.stores.brushOptions.get(), opacity: 0.5 }); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + move(brush, h.ctx, pointer(40, 40), [pointer(40, 40)]); + up(brush, h.ctx, pointer(40, 40)); + + expect(h.strokes).toHaveLength(1); + expect(h.strokes[0]!.layerId).toBe('mask1'); + expect(lastGlobalAlpha(cacheSurface(h, 'mask1'))).toBe(1); + }); + + it('still respects brush opacity on a normal raster paint layer', () => { + const doc = makeDoc([paintLayer('paint1')], 'paint1'); + const h = createHarness(doc); + h.ctx.stores.brushOptions.set({ ...h.ctx.stores.brushOptions.get(), opacity: 0.5 }); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + move(brush, h.ctx, pointer(40, 40), [pointer(40, 40)]); + up(brush, h.ctx, pointer(40, 40)); + + expect(lastGlobalAlpha(cacheSurface(h, 'paint1'))).toBe(0.5); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts new file mode 100644 index 00000000000..fddfc198870 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts @@ -0,0 +1,279 @@ +/** + * The shared machinery behind the brush and eraser tools. Both are the same + * gesture — resolve a paintable target layer on pointer-down (auto-creating a + * fresh paint layer when the selection can't be painted into), drive a + * {@link StrokeSession} across the move batches, and commit or cancel on release — + * differing only in their blend (`source-over` fill vs `destination-out`) and in + * which options store they read. `createBrushTool` / `createEraserTool` + * (in `brushTool.ts` / `eraserTool.ts`) are thin wrappers over + * {@link createPaintTool}. + * + * This is the ONE place a painting tool is allowed to dispatch, and only ever + * the single gesture-start `addCanvasLayer`. Pointer-move never dispatches. + * + * Zero React, zero import-time side effects. + */ + +import type { PointerInput } from '@workbench/canvas-engine/types'; +import type { CanvasLayerContract, CanvasRasterLayerContractV2 } from '@workbench/types'; + +import type { StrokeCommittedEvent, Tool, ToolContext } from './tool'; + +import { createStrokeSession, type StrokeSession } from './strokeSession'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** The resolved config for one gesture, derived from the tool's options store. */ +export interface PaintToolSpec { + id: 'brush' | 'eraser'; + composite: 'source-over' | 'destination-out'; + /** Reads the current size (document units) from the options store. */ + size(ctx: ToolContext): number; + /** Reads the current per-stroke opacity from the options store. */ + opacity(ctx: ToolContext): number; + /** The fill color; `null` for the eraser (shape only). */ + color(ctx: ToolContext): string; + /** Freehand thinning for this gesture; 0 disables pressure sensitivity. */ + thinning(ctx: ToolContext): number; +} + +/** Colour brush strokes paint into a MASK cache: an opaque stencil (only alpha matters). */ +const MASK_STROKE_COLOR = '#ffffff'; + +/** The resolved paint target for a gesture. `createdLayer` is set only when auto-created. */ +interface PaintTarget { + layerId: string; + commit(event: StrokeCommittedEvent): void; + cancel(): void; + /** When the gesture auto-created its layer, the created contract + insert index (for history). */ + createdLayer?: { layer: CanvasLayerContract; index: number }; + /** + * Overrides the brush colour for this gesture (mask targets paint an opaque + * stencil — the stored RGB is irrelevant, the compositor colorizes by alpha). + * Absent ⇒ the tool's own colour. + */ + color?: string; + /** + * True when the target is a transparency-LOCKED raster paint layer. The brush + * then composites `source-atop` (colour only on existing pixels); the eraser is + * refused (erasing would change the locked alpha). Never set for mask targets. + */ + transparencyLocked?: boolean; + /** + * True for mask targets (inpaint / regional guidance). A mask is an opaque + * alpha stencil, so the stroke is forced to opacity 1 regardless of the brush's + * opacity slider — a 50%-opacity brush would otherwise land alpha ~128 and + * silently attenuate the mask (a ~50% denoise, invisible in the tinted overlay). + */ + forceOpaque?: boolean; +} + +/** True for a mask-bearing layer (inpaint mask / regional guidance) — a paintable alpha stencil. */ +const isMaskLayer = (layer: CanvasLayerContract): boolean => + layer.type === 'inpaint_mask' || layer.type === 'regional_guidance'; + +/** Resolves (or auto-creates) the paint target for a gesture, or `null` to no-op. */ +const resolveTarget = (ctx: ToolContext): PaintTarget | null => { + const doc = ctx.getDocument(); + if (!doc) { + return null; + } + const selected = doc.selectedLayerId ? doc.layers.find((layer) => layer.id === doc.selectedLayerId) : undefined; + + if (selected && selected.type === 'raster' && selected.source.type === 'paint') { + // The selection is a paint layer: paint into it, unless it's locked/disabled + // (a no-op — don't silently spawn a new layer over the user's locked target). + if (selected.isLocked || !selected.isEnabled) { + return null; + } + // The cache (if any) keeps its current content extent; the stroke grows it. + return { + cancel: () => undefined, + commit: (event) => ctx.emitStrokeCommitted(event), + layerId: selected.id, + transparencyLocked: selected.isTransparencyLocked === true, + }; + } + + if (selected && isMaskLayer(selected)) { + // The selection is a mask: paint the stroke into its alpha stencil cache + // (brush adds coverage, eraser removes — the shared stroke session handles + // both via its composite op). Never auto-create a paint layer here. A + // locked/hidden mask refuses the stroke (a no-op, not a spawn). + if (selected.isLocked || !selected.isEnabled) { + return null; + } + return { + cancel: () => undefined, + color: MASK_STROKE_COLOR, + commit: (event) => ctx.emitStrokeCommitted(event), + forceOpaque: true, + layerId: selected.id, + }; + } + + if (selected?.type === 'control') { + const transaction = ctx.beginControlPixelEdit?.(selected.id) ?? null; + if (!transaction) { + return null; + } + return { + cancel: transaction.cancel, + commit: transaction.commitStroke, + layerId: transaction.layerId, + }; + } + + // Selection is an image/other raster, another layer type, or nothing: create a + // fresh paint layer (inserted on top and selected by the reducer) and paint + // into it. This is the single allowed gesture-start dispatch. + const layerId = ctx.createLayerId(); + const layer: CanvasRasterLayerContractV2 = { + blendMode: 'normal', + id: layerId, + isEnabled: true, + isLocked: false, + name: `Layer ${doc.layers.length + 1}`, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }; + // Auto-create inserts at the top (index 0); the reducer selects it. + ctx.dispatch({ layer, type: 'addCanvasLayer' }); + + // A brand-new empty paint layer: create a zero-rect cache marked fresh so the + // async rasterize pass doesn't clobber the stroke mid-gesture. The first stroke + // grows it from empty to the stroke's content bounds. + const entry = ctx.layers.getOrCreateRect(layerId, { height: 0, width: 0, x: 0, y: 0 }); + entry.stale = false; + return { + cancel: () => undefined, + commit: (event) => ctx.emitStrokeCommitted(event), + createdLayer: { index: 0, layer }, + layerId, + }; +}; + +/** Creates a brush-family tool from its per-gesture {@link PaintToolSpec}. */ +export const createPaintTool = (spec: PaintToolSpec): Tool => { + let session: StrokeSession | null = null; + let target: PaintTarget | null = null; + + const cursorRadiusDoc = (ctx: ToolContext): number => spec.size(ctx) / 2; + + const updateCursorRing = (ctx: ToolContext, input: PointerInput): void => { + ctx.setOverlayCursor({ point: input.documentPoint, radiusDoc: cursorRadiusDoc(ctx) }); + ctx.invalidate({ overlay: true }); + }; + + const endSession = (): void => { + session = null; + target = null; + }; + + const abortSession = (): void => { + const activeSession = session; + const activeTarget = target; + try { + activeSession?.cancel(); + } finally { + try { + activeTarget?.cancel(); + } finally { + endSession(); + } + } + }; + + return { + cursor: () => 'crosshair', + id: spec.id, + onDeactivate: (ctx, opts) => { + try { + if ((session || target) && !opts?.temporary) { + abortSession(); + } + } finally { + ctx.setOverlayCursor(null); + ctx.invalidate({ overlay: true }); + } + }, + onPointerCancel: () => { + if (session || target) { + abortSession(); + } + }, + onPointerDown: (ctx, input) => { + if (session || (input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + const resolvedTarget = resolveTarget(ctx); + updateCursorRing(ctx, input); + if (!resolvedTarget) { + return; + } + // Transparency lock: the eraser is refused (it would alter the locked alpha); + // the brush switches to `source-atop` so colour lands only on existing pixels. + if (resolvedTarget.transparencyLocked && spec.id === 'eraser') { + return; + } + const composite = resolvedTarget.transparencyLocked && spec.id === 'brush' ? 'source-atop' : spec.composite; + target = resolvedTarget; + try { + session = createStrokeSession({ + // Resolve the selection clip ONCE per gesture: when a selection exists the + // stroke is masked to it; with none the field is null and the hot path is + // untouched (no per-point mask lookup). + clipMask: ctx.getSelectionMask?.() ?? null, + color: target.color ?? spec.color(ctx), + composite, + createdLayer: target.createdLayer ?? null, + ctx, + layerId: target.layerId, + // Mask strokes are forced opaque (an alpha stencil is all-or-nothing); a + // brush-opacity mask stroke would silently attenuate the denoise strength. + opacity: target.forceOpaque ? 1 : spec.opacity(ctx), + size: spec.size(ctx), + thinning: spec.thinning(ctx), + tool: spec.id, + }); + session.addPoints([input]); + } catch { + abortSession(); + } + }, + onPointerMove: (ctx, input, batch) => { + updateCursorRing(ctx, input); + if (session) { + try { + session.addPoints(batch); + } catch (error) { + abortSession(); + throw error; + } + } + }, + onPointerUp: (ctx, input) => { + updateCursorRing(ctx, input); + if (session && target) { + const activeSession = session; + const activeTarget = target; + let event: StrokeCommittedEvent | null; + try { + event = activeSession.commit(); + } catch (error) { + abortSession(); + throw error; + } + endSession(); + if (event) { + activeTarget.commit(event); + } else { + activeTarget.cancel(); + } + } + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/samHitTest.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/samHitTest.test.ts new file mode 100644 index 00000000000..d46e13ad0f3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/samHitTest.test.ts @@ -0,0 +1,93 @@ +import { identity, scale } from '@workbench/canvas-engine/math/mat2d'; +import { describe, expect, it } from 'vitest'; + +import { clipPointToRect, clipRectToRect, moveSamBbox, rectFromPoints, resizeSamBbox, samHitTest } from './samHitTest'; + +const source = { height: 80, width: 100, x: 10, y: 20 }; + +describe('samHitTest', () => { + it.each([1, 4])('keeps point and bbox handle hit regions screen-constant at %sx zoom', (zoom) => { + const view = scale(identity(), zoom); + const point = { x: 30, y: 40 }; + const bbox = { height: 30, width: 40, x: 40, y: 30 }; + + expect( + samHitTest({ + bbox, + excludePoints: [], + includePoints: [point], + screenPoint: { x: point.x * zoom + 5, y: point.y * zoom }, + view, + }) + ).toEqual({ index: 0, kind: 'point', label: 'include' }); + expect( + samHitTest({ + bbox, + excludePoints: [], + includePoints: [], + screenPoint: { x: bbox.x * zoom + 5, y: bbox.y * zoom }, + view, + }) + ).toEqual({ handle: 'nw', kind: 'bbox-handle' }); + }); + + it('prioritizes points, then handles, then the bbox body', () => { + const bbox = { height: 30, width: 40, x: 40, y: 30 }; + const common = { bbox, excludePoints: [{ x: 40, y: 30 }], includePoints: [], view: identity() }; + + expect(samHitTest({ ...common, screenPoint: { x: 40, y: 30 } })).toEqual({ + index: 0, + kind: 'point', + label: 'exclude', + }); + expect(samHitTest({ ...common, excludePoints: [], screenPoint: { x: 80, y: 45 } })).toEqual({ + handle: 'e', + kind: 'bbox-handle', + }); + expect(samHitTest({ ...common, excludePoints: [], screenPoint: { x: 60, y: 45 } })).toEqual({ + kind: 'bbox-body', + }); + expect(samHitTest({ ...common, excludePoints: [], screenPoint: { x: 95, y: 70 } })).toBeNull(); + }); +}); + +describe('SAM bbox geometry', () => { + it('normalizes dragged corners and clips to the source export bounds', () => { + expect(rectFromPoints({ x: 120, y: 120 }, { x: 0, y: 0 }, source)).toEqual(source); + expect(clipRectToRect({ height: 30, width: 30, x: 100, y: 80 }, source)).toEqual({ + height: 20, + width: 10, + x: 100, + y: 80, + }); + }); + + it('clips points and bbox moves without changing bbox size', () => { + expect(clipPointToRect({ x: -5, y: 200 }, source)).toEqual({ x: 10, y: 99 }); + expect(moveSamBbox({ height: 20, width: 30, x: 20, y: 30 }, 500, -500, source)).toEqual({ + height: 20, + width: 30, + x: 80, + y: 20, + }); + }); + + it('resizes from screen-constant handles and clamps to source bounds and one document pixel', () => { + expect( + resizeSamBbox({ + bounds: source, + delta: { x: 100, y: 100 }, + handle: 'nw', + start: { height: 30, width: 40, x: 40, y: 30 }, + }) + ).toEqual({ height: 1, width: 1, x: 79, y: 59 }); + expect( + resizeSamBbox({ + bounds: source, + delta: { x: 100, y: 100 }, + handle: 'se', + start: { height: 30, width: 40, x: 40, y: 30 }, + }) + ).toEqual({ height: 70, width: 70, x: 40, y: 30 }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/samHitTest.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/samHitTest.ts new file mode 100644 index 00000000000..463f1cd32b9 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/samHitTest.ts @@ -0,0 +1,109 @@ +import type { Mat2d, Rect, Vec2 } from '@workbench/canvas-engine/types'; + +import { applyToPoint } from '@workbench/canvas-engine/math/mat2d'; +import { transformBounds } from '@workbench/canvas-engine/math/rect'; +import { canonicalizeDocumentSamPoint } from '@workbench/canvas-engine/samCoordinates'; + +import { BBOX_HANDLES, bboxHandleAt, type BboxHandle, isInsideRect } from './bboxHitTest'; + +export const SAM_POINT_HIT_RADIUS_PX = 8; +export const SAM_BBOX_HANDLE_HIT_PX = 14; + +export type SamPointLabel = 'include' | 'exclude'; + +export type SamHitTarget = + | { kind: 'point'; label: SamPointLabel; index: number } + | { kind: 'bbox-handle'; handle: BboxHandle } + | { kind: 'bbox-body' }; + +export interface SamHitTestOptions { + view: Mat2d; + screenPoint: Vec2; + includePoints: readonly Vec2[]; + excludePoints: readonly Vec2[]; + bbox: Rect | null; +} + +export const samHitTest = (options: SamHitTestOptions): SamHitTarget | null => { + for (const label of ['include', 'exclude'] as const) { + const points = label === 'include' ? options.includePoints : options.excludePoints; + for (let index = points.length - 1; index >= 0; index -= 1) { + const point = points[index]; + if (!point) { + continue; + } + const screen = applyToPoint(options.view, point); + if (Math.hypot(options.screenPoint.x - screen.x, options.screenPoint.y - screen.y) <= SAM_POINT_HIT_RADIUS_PX) { + return { index, kind: 'point', label }; + } + } + } + + if (!options.bbox) { + return null; + } + const screenRect = transformBounds(options.view, options.bbox); + const handle = bboxHandleAt(screenRect, options.screenPoint, SAM_BBOX_HANDLE_HIT_PX); + if (handle) { + return { handle, kind: 'bbox-handle' }; + } + return isInsideRect(screenRect, options.screenPoint) ? { kind: 'bbox-body' } : null; +}; + +const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); + +export const clipPointToRect = (point: Vec2, bounds: Rect): Vec2 => + canonicalizeDocumentSamPoint(point, bounds, true) ?? { x: bounds.x, y: bounds.y }; + +export const clipRectToRect = (rect: Rect, bounds: Rect): Rect => { + const left = clamp(rect.x, bounds.x, bounds.x + bounds.width); + const top = clamp(rect.y, bounds.y, bounds.y + bounds.height); + const right = clamp(rect.x + rect.width, left, bounds.x + bounds.width); + const bottom = clamp(rect.y + rect.height, top, bounds.y + bounds.height); + return { height: bottom - top, width: right - left, x: left, y: top }; +}; + +export const rectFromPoints = (start: Vec2, end: Vec2, bounds: Rect): Rect => + clipRectToRect( + { + height: Math.abs(end.y - start.y), + width: Math.abs(end.x - start.x), + x: Math.min(start.x, end.x), + y: Math.min(start.y, end.y), + }, + bounds + ); + +export const moveSamBbox = (start: Rect, dx: number, dy: number, bounds: Rect): Rect => ({ + height: start.height, + width: start.width, + x: clamp(start.x + dx, bounds.x, bounds.x + bounds.width - start.width), + y: clamp(start.y + dy, bounds.y, bounds.y + bounds.height - start.height), +}); + +const hasWest = (handle: BboxHandle): boolean => BBOX_HANDLES.includes(handle) && handle.includes('w'); +const hasEast = (handle: BboxHandle): boolean => handle.includes('e'); +const hasNorth = (handle: BboxHandle): boolean => handle.includes('n'); +const hasSouth = (handle: BboxHandle): boolean => handle.includes('s'); + +export const resizeSamBbox = (options: { start: Rect; handle: BboxHandle; delta: Vec2; bounds: Rect }): Rect => { + const { bounds, delta, handle, start } = options; + let left = start.x; + let top = start.y; + let right = start.x + start.width; + let bottom = start.y + start.height; + + if (hasWest(handle)) { + left = clamp(start.x + delta.x, bounds.x, right - 1); + } + if (hasEast(handle)) { + right = clamp(start.x + start.width + delta.x, left + 1, bounds.x + bounds.width); + } + if (hasNorth(handle)) { + top = clamp(start.y + delta.y, bounds.y, bottom - 1); + } + if (hasSouth(handle)) { + bottom = clamp(start.y + start.height + delta.y, top + 1, bounds.y + bounds.height); + } + return { height: bottom - top, width: right - left, x: left, y: top }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/samTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/samTool.test.ts new file mode 100644 index 00000000000..e36bd5fa6db --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/samTool.test.ts @@ -0,0 +1,184 @@ +import type { PointerInput } from '@workbench/canvas-engine/types'; +import type { SamSessionSnapshot } from '@workbench/canvas-operations/operationTypes'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { createCanvasOperationStores } from '@workbench/canvas-operations/operationStores'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ToolContext } from './tool'; + +import { createSamTool } from './samTool'; + +const sourceRect = { height: 100, width: 100, x: 0, y: 0 }; + +const visualSnapshot = (overrides: Partial = {}): SamSessionSnapshot => ({ + applyPolygonRefinement: false, + autoProcess: false, + error: null, + hasPreview: false, + input: { bbox: null, excludePoints: [], includePoints: [], type: 'visual' }, + invert: false, + isolatedPreview: true, + layerName: 'Layer 1', + layerType: 'raster', + model: 'segment-anything-2-large', + pointLabel: 'include', + sourceRect, + status: 'ready', + ...overrides, +}); + +const pointer = (x: number, y: number, options: { buttons?: number; shift?: boolean } = {}): PointerInput => ({ + buttons: options.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: false, ctrl: false, meta: false, shift: options.shift ?? false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +const createHarness = (snapshot = visualSnapshot()) => { + const stores = createEngineStores(); + const operationStores = createCanvasOperationStores(); + operationStores.samSession.set(snapshot); + const invalidate = vi.fn(); + const updateSamInput = vi.fn((input: SamSessionSnapshot['input']) => { + const current = operationStores.samSession.get(); + if (current) { + operationStores.samSession.set({ ...current, input }); + } + }); + const ctx = { + getSamInteraction: () => { + const current = operationStores.samSession.get(); + return current?.input.type === 'visual' + ? { input: current.input, pointLabel: current.pointLabel, sourceRect: current.sourceRect } + : null; + }, + invalidate, + stores, + updateCursor: vi.fn(), + updateSamInput, + viewport: { + documentToScreen: (point: { x: number; y: number }) => point, + viewMatrix: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }), + }, + } as unknown as ToolContext; + return { ctx, invalidate, operationStores, stores, tool: createSamTool(), updateSamInput }; +}; + +const down = (h: ReturnType, input: PointerInput): void => h.tool.onPointerDown?.(h.ctx, input); +const move = (h: ReturnType, input: PointerInput): void => + h.tool.onPointerMove?.(h.ctx, input, [input]); +const up = (h: ReturnType, input: PointerInput): void => h.tool.onPointerUp?.(h.ctx, input); + +describe('createSamTool', () => { + it('adds configured points on click and temporarily flips their label with Shift', () => { + const h = createHarness(); + + down(h, pointer(20, 30)); + up(h, pointer(20, 30, { buttons: 0 })); + down(h, pointer(50, 60, { shift: true })); + up(h, pointer(50, 60, { buttons: 0, shift: true })); + + expect(h.operationStores.samSession.get()?.input).toEqual({ + bbox: null, + excludePoints: [{ x: 50, y: 60 }], + includePoints: [{ x: 20, y: 30 }], + type: 'visual', + }); + expect(h.invalidate.mock.calls.every(([payload]) => payload.overlay === true && !payload.all)).toBe(true); + }); + + it('removes an existing point on click but drags it after the screen threshold', () => { + const h = createHarness( + visualSnapshot({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: 20, y: 20 }], type: 'visual' }, + }) + ); + + down(h, pointer(20, 20)); + up(h, pointer(20, 20, { buttons: 0 })); + expect(h.operationStores.samSession.get()?.input.includePoints).toEqual([]); + + h.operationStores.samSession.set( + visualSnapshot({ input: { bbox: null, excludePoints: [], includePoints: [{ x: 20, y: 20 }], type: 'visual' } }) + ); + down(h, pointer(20, 20)); + move(h, pointer(21, 21)); + expect(h.operationStores.samSession.get()?.input.includePoints).toEqual([{ x: 20, y: 20 }]); + move(h, pointer(35, 40)); + up(h, pointer(35, 40, { buttons: 0 })); + expect(h.operationStores.samSession.get()?.input.includePoints).toEqual([{ x: 35, y: 40 }]); + }); + + it('creates a clipped bbox from an empty drag and treats a sub-threshold drag as a click', () => { + const h = createHarness(); + + down(h, pointer(80, 80)); + move(h, pointer(120, 130)); + up(h, pointer(120, 130, { buttons: 0 })); + expect(h.operationStores.samSession.get()?.input.bbox).toEqual({ height: 20, width: 20, x: 80, y: 80 }); + + down(h, pointer(10, 10)); + move(h, pointer(12, 12)); + up(h, pointer(12, 12, { buttons: 0 })); + expect(h.operationStores.samSession.get()?.input.includePoints).toEqual([{ x: 12, y: 12 }]); + }); + + it('does not publish a degenerate bbox when a boundary drag clips to zero area', () => { + const h = createHarness(); + + down(h, pointer(100, 100)); + move(h, pointer(120, 130)); + up(h, pointer(120, 130, { buttons: 0 })); + + expect(h.operationStores.samSession.get()?.input.bbox).toBeNull(); + }); + + it('accepts and canonicalizes a near-edge point but rejects the exact right and bottom edges', () => { + const h = createHarness(); + + down(h, pointer(99.8, 99.6)); + up(h, pointer(99.8, 99.6, { buttons: 0 })); + down(h, pointer(100, 50)); + up(h, pointer(100, 50, { buttons: 0 })); + down(h, pointer(50, 100)); + up(h, pointer(50, 100, { buttons: 0 })); + + expect(h.operationStores.samSession.get()?.input.includePoints).toEqual([{ x: 99, y: 99 }]); + }); + + it('moves the bbox body and resizes a handle while clipping to source bounds', () => { + const h = createHarness( + visualSnapshot({ + input: { bbox: { height: 30, width: 40, x: 20, y: 20 }, excludePoints: [], includePoints: [], type: 'visual' }, + }) + ); + + down(h, pointer(40, 35)); + move(h, pointer(90, 90)); + up(h, pointer(90, 90, { buttons: 0 })); + expect(h.operationStores.samSession.get()?.input.bbox).toEqual({ height: 30, width: 40, x: 60, y: 70 }); + + down(h, pointer(100, 100)); + move(h, pointer(150, 150)); + up(h, pointer(150, 150, { buttons: 0 })); + expect(h.operationStores.samSession.get()?.input.bbox).toEqual({ height: 30, width: 40, x: 60, y: 70 }); + }); + + it('restores the exact pre-gesture input on pointercancel', () => { + const initial = visualSnapshot({ + input: { bbox: null, excludePoints: [{ x: 10, y: 10 }], includePoints: [], type: 'visual' }, + }); + const h = createHarness(initial); + + down(h, pointer(10, 10)); + move(h, pointer(50, 50)); + expect(h.operationStores.samSession.get()?.input.excludePoints).toEqual([{ x: 50, y: 50 }]); + h.tool.onPointerCancel?.(h.ctx); + + expect(h.operationStores.samSession.get()?.input).toEqual(initial.input); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/samTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/samTool.ts new file mode 100644 index 00000000000..93deef28c5f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/samTool.ts @@ -0,0 +1,144 @@ +import type { SamVisualInput } from '@workbench/canvas-engine/samInteraction'; +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; + +import { canonicalizeDocumentSamPoint } from '@workbench/canvas-engine/samCoordinates'; + +import type { SamHitTarget } from './samHitTest'; +import type { Tool, ToolContext } from './tool'; + +import { clipPointToRect, moveSamBbox, rectFromPoints, resizeSamBbox, samHitTest } from './samHitTest'; + +const PRIMARY_BUTTON = 1; +export const SAM_DRAG_THRESHOLD_PX = 3; + +interface GestureState { + startDoc: Vec2; + startScreen: Vec2; + startInput: SamVisualInput; + target: SamHitTarget | null; + moved: boolean; +} + +const cloneInput = (input: SamVisualInput): SamVisualInput => ({ + bbox: input.bbox ? { ...input.bbox } : null, + excludePoints: input.excludePoints.map((point) => ({ ...point })), + includePoints: input.includePoints.map((point) => ({ ...point })), + type: 'visual', +}); + +const flippedLabel = (label: 'include' | 'exclude'): 'include' | 'exclude' => + label === 'include' ? 'exclude' : 'include'; + +export const createSamTool = (): Tool => { + let gesture: GestureState | null = null; + + const publish = (ctx: ToolContext, input: SamVisualInput): void => { + ctx.updateSamInput?.(input); + ctx.invalidate({ overlay: true }); + }; + + const updateGesture = (ctx: ToolContext, input: PointerInput, state: GestureState): void => { + const snapshot = ctx.getSamInteraction?.(); + if (!snapshot || snapshot.input.type !== 'visual') { + return; + } + const next = cloneInput(state.startInput); + const delta = { + x: input.documentPoint.x - state.startDoc.x, + y: input.documentPoint.y - state.startDoc.y, + }; + + if (state.target?.kind === 'point') { + const points = state.target.label === 'include' ? next.includePoints : next.excludePoints; + points[state.target.index] = clipPointToRect(input.documentPoint, snapshot.sourceRect); + } else if (state.target?.kind === 'bbox-body' && next.bbox) { + next.bbox = moveSamBbox(next.bbox, delta.x, delta.y, snapshot.sourceRect); + } else if (state.target?.kind === 'bbox-handle' && next.bbox) { + next.bbox = resizeSamBbox({ bounds: snapshot.sourceRect, delta, handle: state.target.handle, start: next.bbox }); + } else { + const created = rectFromPoints(state.startDoc, input.documentPoint, snapshot.sourceRect); + next.bbox = created.width >= 1 && created.height >= 1 ? created : state.startInput.bbox; + } + publish(ctx, next); + }; + + const cancelGesture = (ctx: ToolContext): void => { + const current = gesture; + gesture = null; + if (current?.moved) { + publish(ctx, cloneInput(current.startInput)); + } + }; + + return { + cursor: () => 'crosshair', + id: 'sam', + onDeactivate: (ctx) => cancelGesture(ctx), + onPointerCancel: (ctx) => cancelGesture(ctx), + onPointerDown: (ctx, input) => { + const snapshot = ctx.getSamInteraction?.(); + if ( + gesture || + !snapshot || + snapshot.input.type !== 'visual' || + (input.buttons & PRIMARY_BUTTON) === 0 || + !canonicalizeDocumentSamPoint(input.documentPoint, snapshot.sourceRect) + ) { + return; + } + gesture = { + moved: false, + startDoc: input.documentPoint, + startInput: cloneInput(snapshot.input), + startScreen: input.screenPoint, + target: samHitTest({ + bbox: snapshot.input.bbox, + excludePoints: snapshot.input.excludePoints, + includePoints: snapshot.input.includePoints, + screenPoint: input.screenPoint, + view: ctx.viewport.viewMatrix(1), + }), + }; + }, + onPointerMove: (ctx, input) => { + if (!gesture) { + return; + } + if (!gesture.moved) { + const dx = input.screenPoint.x - gesture.startScreen.x; + const dy = input.screenPoint.y - gesture.startScreen.y; + if (Math.hypot(dx, dy) < SAM_DRAG_THRESHOLD_PX) { + return; + } + gesture.moved = true; + } + updateGesture(ctx, input, gesture); + }, + onPointerUp: (ctx, input) => { + const current = gesture; + gesture = null; + if (!current) { + return; + } + if (current.moved) { + updateGesture(ctx, input, current); + return; + } + + const snapshot = ctx.getSamInteraction?.(); + if (!snapshot || snapshot.input.type !== 'visual') { + return; + } + const next = cloneInput(current.startInput); + if (current.target?.kind === 'point') { + const points = current.target.label === 'include' ? next.includePoints : next.excludePoints; + points.splice(current.target.index, 1); + } else { + const label = input.modifiers.shift ? flippedLabel(snapshot.pointLabel) : snapshot.pointLabel; + const points = label === 'include' ? next.includePoints : next.excludePoints; + points.push(clipPointToRect(input.documentPoint, snapshot.sourceRect)); + } + publish(ctx, next); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/shapeTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/shapeTool.test.ts new file mode 100644 index 00000000000..cfa876f32ec --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/shapeTool.test.ts @@ -0,0 +1,191 @@ +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { Viewport } from '@workbench/canvas-engine/viewport'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { describe, expect, it, vi } from 'vitest'; + +import { createShapeTool, rectFromDrag } from './shapeTool'; + +const makeDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 96, width: 96, x: 0, y: 0 }, + height: 512, + layers: [], + selectedLayerId: null, + version: 2, + width: 512, +}); + +const identityViewport = { + documentToScreen: (p: Vec2): Vec2 => ({ x: p.x, y: p.y }), +} as unknown as Viewport; + +const pointer = (x: number, y: number, opts: { shift?: boolean; buttons?: number } = {}): PointerInput => ({ + buttons: opts.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: false, ctrl: false, meta: false, shift: opts.shift ?? false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +interface StructuralCommit { + label: string; + forward: CanvasProjectMutation; + inverse: CanvasProjectMutation; +} + +const createHarness = (doc: CanvasDocumentContractV2) => { + const dispatched: CanvasProjectMutation[] = []; + const commits: StructuralCommit[] = []; + const stores = createEngineStores(); + let idCounter = 0; + const ctx: ToolContext = { + backend: null as never, + commitStructural: (label, forward, inverse) => commits.push({ forward, inverse, label }), + createLayerId: () => `shape-${++idCounter}`, + createPath2D: (d) => ({ d }) as unknown as Path2D, + dispatch: (action) => dispatched.push(action), + emitStrokeCommitted: vi.fn(), + getDocument: () => doc, + invalidate: vi.fn(), + layers: null as never, + notifyLayerPainted: vi.fn(), + setLayerTransformOverride: vi.fn(), + setOverlayCursor: vi.fn(), + stores, + updateCursor: vi.fn(), + viewport: identityViewport, + }; + return { commits, ctx, dispatched, previewOf: () => stores.shapePreview.get(), stores }; +}; + +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerMove?.(ctx, i, [i]); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); + +describe('rectFromDrag', () => { + it('normalizes a drag to a positive integer rect', () => { + expect(rectFromDrag({ x: 50, y: 60 }, { x: 20, y: 100 }, false)).toEqual({ + height: 40, + width: 30, + x: 20, + y: 60, + }); + }); + + it('constrains to a square using the larger dimension, preserving direction', () => { + expect(rectFromDrag({ x: 0, y: 0 }, { x: 30, y: 80 }, true)).toEqual({ height: 80, width: 80, x: 0, y: 0 }); + // Dragging up-left: the square extends toward negative both axes. + expect(rectFromDrag({ x: 100, y: 100 }, { x: 70, y: 20 }, true)).toEqual({ + height: 80, + width: 80, + x: 20, + y: 20, + }); + }); +}); + +describe('shape tool: creation', () => { + it('previews on move then commits ONE addCanvasLayer on up', () => { + const h = createHarness(makeDoc()); + const tool = createShapeTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(70, 50)); + expect(h.previewOf()).toEqual({ kind: 'rect', rect: { height: 40, width: 60, x: 10, y: 10 } }); + + up(tool, h.ctx, pointer(70, 50)); + + // Creation goes through commitStructural (undoable), never a bare dispatch. + expect(h.dispatched).toHaveLength(0); + expect(h.commits).toHaveLength(1); + const forward = h.commits[0]?.forward; + expect(forward?.type).toBe('addCanvasLayer'); + if (forward?.type === 'addCanvasLayer' && forward.layer.type === 'raster') { + expect(forward.layer.source).toEqual({ + fill: '#000000', + height: 40, + kind: 'rect', + stroke: null, + strokeWidth: 8, + type: 'shape', + width: 60, + }); + expect(forward.layer.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 10 }); + } + // Inverse removes the created layer. + expect(h.commits[0]?.inverse).toEqual({ ids: ['shape-1'], type: 'removeCanvasLayers' }); + expect(h.previewOf()).toBeNull(); + }); + + it('uses the shape options kind/fill/stroke for the created layer', () => { + const h = createHarness(makeDoc()); + h.stores.shapeOptions.set({ fill: '#ff0000', kind: 'ellipse', stroke: '#0000ff', strokeWidth: 4 }); + const tool = createShapeTool(); + + down(tool, h.ctx, pointer(0, 0)); + move(tool, h.ctx, pointer(100, 100)); + up(tool, h.ctx, pointer(100, 100)); + + const forward = h.commits[0]?.forward; + if ( + forward?.type === 'addCanvasLayer' && + forward.layer.type === 'raster' && + forward.layer.source.type === 'shape' + ) { + expect(forward.layer.source.kind).toBe('ellipse'); + expect(forward.layer.source.fill).toBe('#ff0000'); + expect(forward.layer.source.stroke).toBe('#0000ff'); + expect(forward.layer.source.strokeWidth).toBe(4); + } else { + throw new Error('expected an ellipse shape layer'); + } + }); + + it('constrains to a square while shift is held', () => { + const h = createHarness(makeDoc()); + const tool = createShapeTool(); + down(tool, h.ctx, pointer(0, 0, { shift: true })); + move(tool, h.ctx, pointer(30, 80, { shift: true })); + up(tool, h.ctx, pointer(30, 80, { shift: true })); + const forward = h.commits[0]?.forward; + if ( + forward?.type === 'addCanvasLayer' && + forward.layer.type === 'raster' && + forward.layer.source.type === 'shape' + ) { + expect(forward.layer.source.width).toBe(80); + expect(forward.layer.source.height).toBe(80); + } else { + throw new Error('expected a shape layer'); + } + }); + + it('commits nothing for a zero-area drag', () => { + const h = createHarness(makeDoc()); + const tool = createShapeTool(); + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(10, 10)); + up(tool, h.ctx, pointer(10, 10)); + expect(h.commits).toHaveLength(0); + expect(h.previewOf()).toBeNull(); + }); + + it('escape (cancel key command) drops the gesture without committing', () => { + const h = createHarness(makeDoc()); + const tool = createShapeTool(); + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(70, 50)); + expect(h.previewOf()).not.toBeNull(); + tool.onKeyCommand?.(h.ctx, 'cancel'); + expect(h.previewOf()).toBeNull(); + // A subsequent up does nothing (gesture already dropped). + up(tool, h.ctx, pointer(70, 50)); + expect(h.commits).toHaveLength(0); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/shapeTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/shapeTool.ts new file mode 100644 index 00000000000..5bee9a84d06 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/shapeTool.ts @@ -0,0 +1,159 @@ +/** + * The shape tool: drag on the canvas to CREATE a new shape layer sized by the + * drag rect. Scope is creation-only — dragging never edits an existing shape + * (param edits happen through the options bar and the transform tool). + * + * Interaction contract (CANVAS_PLAN Phase 6.1): + * - **Pointer-down** (primary button) starts a gesture at the press point. + * - **Pointer-move** (past a small threshold) updates a transient overlay + * preview rect (`stores.shapePreview`) — it never dispatches. Hold **shift** + * to constrain to a square/circle. + * - **Commit** (pointer-up after a real drag): exactly one `commitStructural` + * whose forward is `addCanvasLayer` (a shape source sized to the rect, placed + * at the rect origin) and whose inverse is `removeCanvasLayers` — so undo + * removes the created layer. A zero-area drag commits nothing. + * - **Cancel** (Esc / pointercancel): drops the preview, no dispatch. + * + * The selection mask does NOT constrain shape creation — a shape layer is a + * parametric object, not a pixel edit into an existing layer. + * + * Zero React, zero import-time side effects. + */ + +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasRasterLayerContractV2 } from '@workbench/types'; + +import type { Tool, ToolContext } from './tool'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** Screen-space distance (CSS px) the pointer must travel before a press becomes a drag. */ +export const SHAPE_DRAG_THRESHOLD_PX = 3; + +interface GestureState { + startDoc: Vec2; + startScreen: Vec2; + moved: boolean; +} + +/** The integer, normalized document rect for a drag from `start` to `end`, optionally square-constrained. */ +export const rectFromDrag = (start: Vec2, end: Vec2, square: boolean): Rect => { + let dx = end.x - start.x; + let dy = end.y - start.y; + if (square) { + const side = Math.max(Math.abs(dx), Math.abs(dy)); + dx = (dx < 0 ? -1 : 1) * side; + dy = (dy < 0 ? -1 : 1) * side; + } + const x = Math.round(Math.min(start.x, start.x + dx)); + const y = Math.round(Math.min(start.y, start.y + dy)); + return { height: Math.round(Math.abs(dy)), width: Math.round(Math.abs(dx)), x, y }; +}; + +/** Creates a fresh shape tool with its own gesture state. */ +export const createShapeTool = (): Tool => { + let state: GestureState | null = null; + + const clearPreview = (ctx: ToolContext): void => { + ctx.stores.shapePreview.set(null); + ctx.invalidate({ overlay: true }); + }; + + return { + cursor: () => 'crosshair', + id: 'shape', + onDeactivate: (ctx) => { + state = null; + clearPreview(ctx); + }, + onKeyCommand: (ctx, command) => { + if (command === 'cancel' && state) { + state = null; + clearPreview(ctx); + } + }, + onPointerCancel: (ctx) => { + state = null; + clearPreview(ctx); + }, + onPointerDown: (ctx, input) => { + if (state || (input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + if (!ctx.getDocument()) { + return; + } + state = { moved: false, startDoc: input.documentPoint, startScreen: input.screenPoint }; + }, + onPointerMove: (ctx, input) => { + if (!state) { + return; + } + if (!state.moved) { + const dxs = input.screenPoint.x - state.startScreen.x; + const dys = input.screenPoint.y - state.startScreen.y; + if (Math.hypot(dxs, dys) < SHAPE_DRAG_THRESHOLD_PX) { + return; + } + state.moved = true; + } + const rect = rectFromDrag(state.startDoc, input.documentPoint, input.modifiers.shift); + ctx.stores.shapePreview.set({ kind: ctx.stores.shapeOptions.get().kind, rect }); + ctx.invalidate({ overlay: true }); + }, + onPointerUp: (ctx, input) => { + if (!state) { + return; + } + const current = state; + state = null; + + if (!current.moved) { + clearPreview(ctx); + return; + } + + const rect = rectFromDrag(current.startDoc, input.documentPoint, input.modifiers.shift); + if (rect.width < 1 || rect.height < 1) { + // Degenerate drag: nothing to create. + clearPreview(ctx); + return; + } + + const doc = ctx.getDocument(); + if (!doc) { + clearPreview(ctx); + return; + } + + const options = ctx.stores.shapeOptions.get(); + const layerId = ctx.createLayerId(); + const layer: CanvasRasterLayerContractV2 = { + blendMode: 'normal', + id: layerId, + isEnabled: true, + isLocked: false, + name: `Shape ${doc.layers.length + 1}`, + opacity: 1, + source: { + fill: options.fill, + height: rect.height, + kind: options.kind, + stroke: options.stroke, + strokeWidth: options.strokeWidth, + type: 'shape', + width: rect.width, + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: rect.x, y: rect.y }, + type: 'raster', + }; + + const forward: CanvasProjectMutation = { index: 0, layer, type: 'addCanvasLayer' }; + const inverse: CanvasProjectMutation = { ids: [layerId], type: 'removeCanvasLayers' }; + ctx.commitStructural('Add shape', forward, inverse); + clearPreview(ctx); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.test.ts new file mode 100644 index 00000000000..6304b3a82ac --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.test.ts @@ -0,0 +1,282 @@ +import type { LayerCacheEntry } from '@workbench/canvas-engine/render/layerCache'; +import type { StubRasterBackend, StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput } from '@workbench/canvas-engine/types'; + +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { createStrokeSession } from '@workbench/canvas-engine/tools/strokeSession'; +import { describe, expect, it, vi } from 'vitest'; + +const pointer = (x: number, y: number): PointerInput => ({ + buttons: 1, + documentPoint: { x, y }, + modifiers: { alt: false, ctrl: false, meta: false, shift: false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +/** A capturing backend so the stroke scratch surface can be identified. */ +const createCapturingBackend = (): { backend: StubRasterBackend; created: StubRasterSurface[] } => { + const inner = createTestStubRasterBackend(); + const created: StubRasterSurface[] = []; + return { + backend: { + ...inner, + createSurface: (w, h) => { + const s = inner.createSurface(w, h); + created.push(s); + return s; + }, + }, + created, + }; +}; + +const runStroke = (opts: { withMask: boolean }) => { + const { backend, created } = createCapturingBackend(); + const layers = createLayerCacheStore(backend); + const entry: LayerCacheEntry = layers.getOrCreate('L', 100, 100); + const mask = opts.withMask ? backend.createSurface(100, 100) : null; + const clipMask = mask ? { rect: { height: 100, width: 100, x: 0, y: 0 }, surface: mask } : null; + const emitStrokeCommitted = vi.fn(); + const notifyLayerPainted = vi.fn(); + + const ctx = { + backend, + createPath2D: (d?: string) => ({ d }) as unknown as Path2D, + emitStrokeCommitted, + invalidate: vi.fn(), + layers, + notifyLayerPainted, + } as unknown as ToolContext; + + // Only the scratch is created after this point. + created.length = 0; + const session = createStrokeSession({ + clipMask, + color: '#ff0000', + composite: 'source-over', + ctx, + layerId: 'L', + opacity: 1, + size: 20, + thinning: 0, + tool: 'brush', + }); + session.addPoints([pointer(10, 10)]); + session.addPoints([pointer(40, 10), pointer(40, 40)]); + const event = session.commit(); + + const scratch = created[0]!; + return { cache: entry.surface as StubRasterSurface, emitStrokeCommitted, event, notifyLayerPainted, scratch }; +}; + +const compositeOps = (surface: StubRasterSurface): unknown[] => + surface.callLog.filter((e) => e.op === 'set' && e.args[0] === 'globalCompositeOperation').map((e) => e.args[1]); + +describe('strokeSession: selection-constrained painting', () => { + it('with a clip mask, intersects the scratch stroke with the mask (destination-in) before compositing', () => { + const { scratch } = runStroke({ withMask: true }); + expect(compositeOps(scratch)).toContain('destination-in'); + // The mask is drawn into the scratch to clip it. + expect(scratch.callLog.some((e) => e.op === 'drawImage')).toBe(true); + }); + + it('without a clip mask, the scratch stays a plain filled stroke (no extra clip ops)', () => { + const { scratch } = runStroke({ withMask: false }); + expect(compositeOps(scratch)).not.toContain('destination-in'); + expect(scratch.callLog.some((e) => e.op === 'drawImage')).toBe(false); + expect(scratch.callLog.filter((e) => e.op === 'fill')).not.toHaveLength(0); + }); + + it('applies the selection clip on the scratch, not by changing the cache composite ops', () => { + const withMask = runStroke({ withMask: true }); + const noMask = runStroke({ withMask: false }); + // The clip is a `destination-in` on the SCRATCH; the layer-cache composite op + // sequence (source-over blit of the clipped stroke) is unchanged — the clip + // never adds a mask op to the cache itself. (The cache's content EXTENT can + // differ: without a selection the cache grows to the stroke's true bounds, + // while a selection bounds the growth to the mask — content-sized behavior.) + expect(compositeOps(withMask.cache)).toEqual(compositeOps(noMask.cache)); + expect(compositeOps(withMask.cache)).not.toContain('destination-in'); + }); + + it('returns a commit with a dirty rect the mask does not shift', () => { + const { event } = runStroke({ withMask: true }); + expect(event!.dirtyRect.width).toBeGreaterThan(0); + expect(event!.dirtyRect.height).toBeGreaterThan(0); + }); +}); + +describe('strokeSession: content-sized cache growth', () => { + const makeSession = (initialRect: { x: number; y: number; width: number; height: number }) => { + const { backend } = createCapturingBackend(); + const layers = createLayerCacheStore(backend); + const entry = layers.getOrCreateRect('L', initialRect); + entry.stale = false; + const emitStrokeCommitted = vi.fn(); + const notifyLayerPainted = vi.fn(); + const ctx = { + backend, + createPath2D: (d?: string) => ({ d }) as unknown as Path2D, + emitStrokeCommitted, + invalidate: vi.fn(), + layers, + notifyLayerPainted, + } as unknown as ToolContext; + const session = createStrokeSession({ + clipMask: null, + color: '#ff0000', + composite: 'source-over', + ctx, + layerId: 'L', + opacity: 1, + size: 20, + thinning: 0, + tool: 'brush', + }); + return { emitStrokeCommitted, entry, notifyLayerPainted, session }; + }; + + it('grows an EMPTY (brand-new) paint cache to the stroke bounds on the first stroke', () => { + const { entry, session } = makeSession({ height: 0, width: 0, x: 0, y: 0 }); + session.addPoints([pointer(50, 60)]); + const event = session.commit(); + + // The cache adopted the stroke's content bounds, snapped OUTWARD to the 64px + // growth-chunk grid — still content-sized (a couple of chunks), NOT an + // origin-anchored document-sized surface. A size-20 dab at (50,60) sits roughly + // at [40,60]×[50,70], which chunk-pads to a small chunk-aligned rect. + expect(entry.rect.width).toBeGreaterThan(0); + expect(entry.rect.height).toBeGreaterThan(0); + // Chunk-aligned extent (origin and size are multiples of the 64px chunk). + expect(entry.rect.x % 64).toBe(0); + expect(entry.rect.y % 64).toBe(0); + expect(entry.rect.width % 64).toBe(0); + expect(entry.rect.height % 64).toBe(0); + // Content-sized: a few chunks around the dab, not a huge (document) surface. + expect(entry.rect.width).toBeLessThanOrEqual(128); + expect(entry.rect.height).toBeLessThanOrEqual(128); + // The padded extent still fully contains the painted dab center (50,60). + expect(entry.rect.x).toBeLessThanOrEqual(50); + expect(entry.rect.x + entry.rect.width).toBeGreaterThan(50); + expect(entry.rect.y).toBeLessThanOrEqual(60); + expect(entry.rect.y + entry.rect.height).toBeGreaterThan(60); + expect(entry.surface.width).toBe(entry.rect.width); + expect(entry.surface.height).toBe(entry.rect.height); + // The committed dirty rect is the same (chunk-padded) layer-local region. + expect(event!.dirtyRect).toEqual(entry.rect); + }); + + it('returns the completed event without publishing engine side effects', () => { + const { emitStrokeCommitted, notifyLayerPainted, session } = makeSession({ height: 0, width: 0, x: 0, y: 0 }); + session.addPoints([pointer(10, 10)]); + const event = session.commit(); + + expect(event).toMatchObject({ layerId: 'L', tool: 'brush' }); + expect(event!.dirtyRect.width).toBeGreaterThan(0); + expect(event!.dirtyRect.height).toBeGreaterThan(0); + expect(emitStrokeCommitted).not.toHaveBeenCalled(); + expect(notifyLayerPainted).not.toHaveBeenCalled(); + }); + + it('returns null when the gesture produced no dirty pixels', () => { + const { session } = makeSession({ height: 0, width: 0, x: 0, y: 0 }); + expect(session.commit()).toBeNull(); + }); + + it('grows an existing cache to the UNION of its extent and an out-of-extent stroke (negative coords included)', () => { + const { entry, session } = makeSession({ height: 20, width: 20, x: 0, y: 0 }); + session.addPoints([pointer(-40, -40)]); + session.commit(); + + // Union of the pre-stroke [0,20)² extent and the stroke bounds around + // (-40,-40): the origin moved into negative layer-local space and the old + // extent's far edge is still covered. + expect(entry.rect.x).toBeLessThan(-30); + expect(entry.rect.y).toBeLessThan(-30); + expect(entry.rect.x + entry.rect.width).toBeGreaterThanOrEqual(20); + expect(entry.rect.y + entry.rect.height).toBeGreaterThanOrEqual(20); + expect(entry.surface.width).toBe(entry.rect.width); + expect(entry.surface.height).toBe(entry.rect.height); + }); + + it('reallocates the cache surface O(stroke / chunk) times — NOT once per batch — across an extending drag', () => { + // Pre-size the cache to a chunk-aligned rect already covering the first dab, so + // only genuine growth (not the empty-cache adoption) counts as a reallocation. + const { entry, session } = makeSession({ height: 64, width: 64, x: 64, y: 64 }); + const surface = entry.surface as StubRasterSurface; + const resizeCount = (): number => surface.callLog.filter((e) => e.op === 'resize').length; + + // Ten 10px batches extending the stroke 100px rightward within a single chunk + // row. Without chunk-padding, growToRect grows to the EXACT union every batch, + // so each batch reallocates + full-copies the cache (≈10 resizes). With the + // 64px chunk grid, successive small extensions land inside the padded extent, + // so the surface reallocates only when the stroke crosses a chunk boundary + // (~100 / 64 ≈ 2 times). + const batches = 10; + for (let i = 0; i < batches; i++) { + session.addPoints([pointer(100 + i * 10, 100)]); + } + session.commit(); + + const resizes = resizeCount(); + expect(resizes).toBeLessThanOrEqual(2); + // Sanity: far fewer reallocations than batches — the unpadded behavior this + // regression guards would resize on nearly every batch. + expect(resizes).toBeLessThan(batches); + }); +}); + +describe('strokeSession: cache version bump (live adjusted-surface invalidation)', () => { + const makeVersionSession = () => { + const { backend } = createCapturingBackend(); + const layers = createLayerCacheStore(backend); + const entry = layers.getOrCreate('L', 100, 100); + entry.stale = false; + const ctx = { + backend, + createPath2D: (d?: string) => ({ d }) as unknown as Path2D, + emitStrokeCommitted: vi.fn(), + invalidate: vi.fn(), + layers, + notifyLayerPainted: vi.fn(), + } as unknown as ToolContext; + const session = createStrokeSession({ + clipMask: null, + color: '#ff0000', + composite: 'source-over', + ctx, + layerId: 'L', + opacity: 1, + size: 20, + thinning: 0, + tool: 'brush', + }); + return { entry, session }; + }; + + it('bumps the cache version on every mid-stroke frame (so the adjusted-surface memo recomputes live)', () => { + const { entry, session } = makeVersionSession(); + const v0 = entry.version; + session.addPoints([pointer(10, 10)]); + const v1 = entry.version; + session.addPoints([pointer(40, 40)]); + const v2 = entry.version; + // Each painted frame advances the version — a version-keyed adjusted surface + // would otherwise serve stale (pre-stroke) adjusted pixels mid-stroke. + expect(v1).toBeGreaterThan(v0); + expect(v2).toBeGreaterThan(v1); + }); + + it('bumps the version on cancel so the restored pixels re-derive the adjusted surface', () => { + const { entry, session } = makeVersionSession(); + session.addPoints([pointer(10, 10)]); + const vBeforeCancel = entry.version; + session.cancel(); + expect(entry.version).toBeGreaterThan(vBeforeCancel); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.ts new file mode 100644 index 00000000000..211e492d247 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.ts @@ -0,0 +1,275 @@ +/** + * The per-gesture paint session shared by the brush and eraser tools. + * + * A session is created on pointer-down against a resolved target layer's cache + * surface and lives until commit (pointer-up) or cancel (Esc / pointercancel). + * It owns the hot path the plan pins as invariant: coalesced points accumulate, + * and on each batch the full freehand outline is filled into a scratch surface + * at **full alpha** and composited into the layer cache at the stroke's opacity — + * so overlapping segments within one stroke never darken (the "stroke buffer" + * approach). Brush composites `source-over` in the fill color; eraser composites + * `destination-out`. When the target raster layer's transparency is LOCKED, the + * brush composites `source-atop` instead, so colour only lands on already-opaque + * pixels and the layer's alpha channel is never grown (legacy "lock transparent + * pixels"). The eraser is refused entirely on a transparency-locked layer (it + * would alter alpha), handled by the tool before a session is created. + * + * ## Per-frame restore/recapture + * + * The cache is the live preview, so it must show `before ∪ stroke@opacity` each + * frame. To recomposite without compounding opacity, every frame first restores + * the previously-painted region from the captured "before" pixels, then + * recaptures the pristine "before" for the (monotonically growing) dirty region, + * then composites the whole accumulated stroke once. This keeps `beforeImageData` + * exactly equal to the pre-stroke pixels over the final dirty rect — which is + * what commit hands to history — and makes cancel a single `putImageData`. + * + * Everything flows through the {@link RasterSurface} `ctx` seam, so this runs + * unchanged on the node test stub. Zero React, zero dispatch on the move path. + */ + +import type { LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { PlacedSurface, PointerInput, Rect } from '@workbench/canvas-engine/types'; +import type { CanvasLayerContract } from '@workbench/types'; + +import { strokeToPath, type StrokeSamplePoint } from '@workbench/canvas-engine/freehand'; +import { intersect, isEmpty, roundOut, union } from '@workbench/canvas-engine/math/rect'; + +import type { StrokeCommittedEvent, ToolContext } from './tool'; + +/** Everything a stroke session needs, resolved by the owning tool on pointer-down. */ +export interface StrokeSessionConfig { + ctx: ToolContext; + /** The layer being painted into (its cache grows with the stroke). */ + layerId: string; + /** Base stroke diameter (document units). */ + size: number; + /** Per-stroke opacity in [0, 1]. */ + opacity: number; + /** Freehand thinning; 0 disables pressure sensitivity. */ + thinning: number; + /** Fill color (brush only; ignored for the eraser). */ + color: string; + /** + * Cache composite operation: `source-over` (brush), `destination-out` (eraser), + * or `source-atop` (transparency-locked brush — colour only where the layer is + * already opaque, alpha never grows). + */ + composite: 'source-over' | 'destination-out' | 'source-atop'; + tool: 'brush' | 'eraser'; + /** Set only when this gesture auto-created its paint layer (for the composed history entry). */ + createdLayer?: { layer: CanvasLayerContract; index: number } | null; + /** + * The bounded selection mask to clip the stroke to (resolved once by the tool + * on pointer-down when a selection exists), as a placed surface in document + * (= layer-local) space. When set, the paint region is intersected with the + * mask bounds and the scratch stroke is masked (`destination-in`) before + * compositing, so pixels outside the selection are never written and the cache + * only grows within the selection. Absent ⇒ the no-selection hot path. + */ + clipMask?: PlacedSurface | null; +} + +/** The imperative handle a tool drives across a gesture. */ +export interface StrokeSession { + /** Appends coalesced samples and repaints the accumulated stroke. */ + addPoints(inputs: readonly PointerInput[]): void; + /** Finalizes the stroke and returns the completed edit for its owner to publish. */ + commit(): StrokeCommittedEvent | null; + /** Restores the pre-stroke pixels and drops the session without an event. */ + cancel(): void; +} + +const toSample = (input: PointerInput): StrokeSamplePoint => ({ + pressure: input.pressure, + x: input.documentPoint.x, + y: input.documentPoint.y, +}); + +/** + * Cache/scratch growth is snapped OUTWARD to this pixel grid. Without it, an + * outward brush drag extends the paint region by a few pixels on every batch, so + * the cache (and the scratch) would reallocate + full-copy on every pointer-move. + * Snapping to a coarse chunk grid means successive small extensions land inside + * the current padded extent, so growth happens at most once per chunk crossed — + * O(stroke / CHUNK) reallocations instead of O(batches). + */ +const GROWTH_CHUNK = 64; + +/** Rounds a rect OUTWARD to the {@link GROWTH_CHUNK} grid (integer, chunk-aligned). */ +const padToChunk = (r: Rect): Rect => { + const x = Math.floor(r.x / GROWTH_CHUNK) * GROWTH_CHUNK; + const y = Math.floor(r.y / GROWTH_CHUNK) * GROWTH_CHUNK; + const right = Math.ceil((r.x + r.width) / GROWTH_CHUNK) * GROWTH_CHUNK; + const bottom = Math.ceil((r.y + r.height) / GROWTH_CHUNK) * GROWTH_CHUNK; + return { height: bottom - y, width: right - x, x, y }; +}; + +/** Creates a paint session that grows the target layer's content-sized cache. */ +export const createStrokeSession = (config: StrokeSessionConfig): StrokeSession => { + const { clipMask, color, composite, createdLayer, ctx, layerId, opacity, size, thinning, tool } = config; + + const layers: LayerCacheStore = ctx.layers; + // A per-frame scratch surface for the filled stroke, sized to the paint region. + let stroke: RasterSurface | null = null; + + const points: StrokeSamplePoint[] = []; + // `beforeImageData` holds the pristine (pre-stroke) pixels of `accumRect`, in + // LAYER-LOCAL coordinates — so it stays valid across a cache growth-realloc + // (which only shifts the surface origin, not the layer-local geometry). + let beforeImageData: ImageData | null = null; + let accumRect: Rect | null = null; + + /** Ensures the scratch surface is at least `w`×`h`. */ + const ensureStroke = (w: number, h: number): RasterSurface => { + if (!stroke) { + stroke = ctx.backend.createSurface(w, h); + } else if (stroke.width < w || stroke.height < h) { + stroke.resize(Math.max(stroke.width, w), Math.max(stroke.height, h)); + } + return stroke; + }; + + const paint = (last: boolean): void => { + if (points.length === 0) { + return; + } + const { bounds, path } = strokeToPath(points, { last, size, thinning }, ctx.createPath2D); + let dirty: Rect | null = roundOut(bounds); + // Selection clip: only the region inside the selection can ever change, so + // bound the dirty/growth region to the mask extent (and skip empty results). + if (clipMask) { + dirty = intersect(dirty, clipMask.rect); + } + if (!dirty || isEmpty(dirty)) { + return; + } + // Accumulate the dirty union, then round it OUTWARD to a coarse chunk grid. + // Chunk-padding is the allocation-light seam the plan pins as invariant: an + // extending drag now grows the cache/scratch at most once per chunk it crosses + // instead of on every pointer batch. The padded extent may exceed the true + // content bounds — fine: it is an internal cache extent, so the flush just + // encodes a slightly larger (mostly-transparent) PNG at a consistent offset, + // and the reported dirty/before/after all use this same padded region so every + // consumer stays coherent. When a selection clips the stroke, clamp the padded + // region back to the mask so growth still never escapes the selection. + let region = padToChunk(accumRect ? roundOut(union(accumRect, dirty)) : dirty); + if (clipMask) { + const clamped = intersect(region, clipMask.rect); + if (clamped) { + region = clamped; + } + } + if (isEmpty(region)) { + return; + } + + // Grow the cache to cover the (chunk-padded, layer-local) region, preserving + // existing pixels. Because `region` is chunk-aligned and monotonically growing, + // `entry.surface` is reallocated at most once per chunk boundary the stroke + // crosses — not once per batch — keeping the hot path allocation-light. The + // surface is resized in place (identity preserved). + const entry = layers.growToRect(layerId, region); + const target = entry.surface; + const targetCtx = target.ctx; + // Surface origin in layer-local space: surface(sx,sy) ↔ local(ox+sx, oy+sy). + const ox = entry.rect.x; + const oy = entry.rect.y; + + // 1. Restore the region painted last frame back to pristine "before" pixels, + // so recompositing the (larger) stroke doesn't compound its opacity. + if (accumRect && beforeImageData) { + targetCtx.putImageData(beforeImageData, accumRect.x - ox, accumRect.y - oy); + } + // 2. Recapture the pristine "before" for the grown region (in surface coords). + beforeImageData = targetCtx.getImageData(region.x - ox, region.y - oy, region.width, region.height); + accumRect = region; + + // 3. Render the whole accumulated stroke into the scratch surface at full + // alpha — one filled polygon, so no self-overlap darkening. The scratch is + // region-local: translate the (layer-local) path by -region.origin. + const scratch = ensureStroke(region.width, region.height); + const strokeCtx = scratch.ctx; + strokeCtx.setTransform(1, 0, 0, 1, -region.x, -region.y); + strokeCtx.clearRect(region.x, region.y, region.width, region.height); + strokeCtx.globalCompositeOperation = 'source-over'; + strokeCtx.globalAlpha = 1; + strokeCtx.fillStyle = color; + strokeCtx.fill(path); + + // 3b. Selection clip: keep only the stroke pixels inside the selection mask. + // The mask is a placed surface; draw it at (maskOrigin - regionOrigin) in + // the scratch's region-local space. + if (clipMask) { + strokeCtx.setTransform(1, 0, 0, 1, 0, 0); + strokeCtx.globalCompositeOperation = 'destination-in'; + strokeCtx.globalAlpha = 1; + strokeCtx.drawImage(clipMask.surface.canvas, clipMask.rect.x - region.x, clipMask.rect.y - region.y); + } + + // 4. Composite the scratch stroke into the cache over the region at the stroke + // opacity, using the tool's blend (brush over / eraser out). Both are in + // surface coords (region translated by the surface origin). + targetCtx.save(); + targetCtx.setTransform(1, 0, 0, 1, 0, 0); + targetCtx.beginPath(); + targetCtx.rect(region.x - ox, region.y - oy, region.width, region.height); + targetCtx.clip(); + targetCtx.globalAlpha = opacity; + targetCtx.globalCompositeOperation = composite; + targetCtx.drawImage(scratch.canvas, region.x - ox, region.y - oy); + targetCtx.restore(); + + // The cache pixels changed THIS frame — bump the version so a version-keyed + // dependent (the memoized adjusted-surface cache) recomputes over the live + // stroke. Without this the compositor would keep serving the pre-stroke + // adjusted surface, and the live stroke would be invisible on an adjusted + // raster layer until pointer-up (and jump on rect growth). This does NOT + // touch `thumbnailVersion` (only `notifyLayerPainted`/rasterize do), so + // thumbnails don't churn mid-stroke. + layers.publishPixels(layerId); + ctx.invalidate({ layers: [layerId] }); + }; + + return { + addPoints: (inputs) => { + for (const input of inputs) { + points.push(toSample(input)); + } + paint(false); + }, + cancel: () => { + const entry = layers.get(layerId); + if (entry && accumRect && beforeImageData) { + entry.surface.ctx.putImageData(beforeImageData, accumRect.x - entry.rect.x, accumRect.y - entry.rect.y); + // Bump the version so the adjusted-surface memo (which recomputed over the + // live stroke) re-derives from the RESTORED pixels — otherwise an adjusted + // raster layer would keep showing the cancelled stroke's adjusted preview. + layers.publishPixels(layerId); + ctx.invalidate({ layers: [layerId] }); + } + }, + commit: () => { + paint(true); + const entry = layers.get(layerId); + if (!entry || !accumRect || !beforeImageData) { + return null; + } + const afterImageData = entry.surface.ctx.getImageData( + accumRect.x - entry.rect.x, + accumRect.y - entry.rect.y, + accumRect.width, + accumRect.height + ); + return { + afterImageData, + beforeImageData, + dirtyRect: accumRect, + layerId, + tool, + ...(createdLayer ? { createdLayer } : {}), + }; + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/textTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/textTool.test.ts new file mode 100644 index 00000000000..31cd513e97f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/textTool.test.ts @@ -0,0 +1,192 @@ +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { describe, expect, it, vi } from 'vitest'; + +import { createTextTool } from './textTool'; + +const textLayer = (over: Partial = {}): CanvasLayerContract => + ({ + blendMode: 'normal', + id: 'text-existing', + isEnabled: true, + isLocked: false, + name: 'Text', + opacity: 1, + source: { + align: 'left', + color: '#000000', + content: 'hello', + fontFamily: 'Inter', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...over, + }) as CanvasLayerContract; + +const makeDoc = (over: Partial = {}): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 96, width: 96, x: 0, y: 0 }, + height: 512, + layers: [], + selectedLayerId: null, + version: 2, + width: 512, + ...over, +}); + +const pointer = (x: number, y: number, buttons = 1): PointerInput => ({ + buttons, + documentPoint: { x, y }, + modifiers: { alt: false, ctrl: false, meta: false, shift: false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +const createHarness = (doc: CanvasDocumentContractV2) => { + const stores = createEngineStores(); + const openTextCreate = vi.fn<(point: Vec2) => void>(); + const openTextEdit = vi.fn<(layerId: string) => void>(); + const cancelTextEdit = vi.fn<() => void>(); + const ctx = { + cancelTextEdit, + getDocument: () => doc, + // No layer cache in these tests, so hit-testing falls back to the pure + // estimateTextExtent (a `get` that always misses). + layers: { get: () => undefined }, + openTextCreate, + openTextEdit, + stores, + } as unknown as ToolContext; + return { cancelTextEdit, ctx, openTextCreate, openTextEdit, stores }; +}; + +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); + +describe('text tool: click behaviour', () => { + it('opens a create-mode session at the click point on empty area', () => { + const h = createHarness(makeDoc()); + const tool = createTextTool(); + + down(tool, h.ctx, pointer(40, 60)); + + expect(h.openTextCreate).toHaveBeenCalledTimes(1); + expect(h.openTextCreate).toHaveBeenCalledWith({ x: 40, y: 60 }); + expect(h.openTextEdit).not.toHaveBeenCalled(); + }); + + it('opens an edit-mode session when the click hits an existing text layer', () => { + const doc = makeDoc({ layers: [textLayer()] }); + const h = createHarness(doc); + const tool = createTextTool(); + + // 'hello' at 20px → estimated block ~60×24, so (5,5) is inside. + down(tool, h.ctx, pointer(5, 5)); + + expect(h.openTextEdit).toHaveBeenCalledTimes(1); + expect(h.openTextEdit).toHaveBeenCalledWith('text-existing'); + expect(h.openTextCreate).not.toHaveBeenCalled(); + }); + + it('creates rather than edits when the click misses the text block', () => { + const doc = makeDoc({ layers: [textLayer()] }); + const h = createHarness(doc); + const tool = createTextTool(); + + down(tool, h.ctx, pointer(500, 500)); + + expect(h.openTextEdit).not.toHaveBeenCalled(); + expect(h.openTextCreate).toHaveBeenCalledTimes(1); + }); + + it('does not edit a locked text layer (falls through to create)', () => { + const doc = makeDoc({ layers: [textLayer({ isLocked: true })] }); + const h = createHarness(doc); + const tool = createTextTool(); + + down(tool, h.ctx, pointer(5, 5)); + + expect(h.openTextEdit).not.toHaveBeenCalled(); + expect(h.openTextCreate).toHaveBeenCalledTimes(1); + }); + + it('does not edit a hidden (disabled) text layer', () => { + const doc = makeDoc({ layers: [textLayer({ isEnabled: false })] }); + const h = createHarness(doc); + const tool = createTextTool(); + + down(tool, h.ctx, pointer(5, 5)); + + expect(h.openTextEdit).not.toHaveBeenCalled(); + expect(h.openTextCreate).toHaveBeenCalledTimes(1); + }); + + it('is a no-op while a session is already open (the click blurs/commits React-side)', () => { + const h = createHarness(makeDoc()); + h.stores.textEditSession.set({ + id: 1, + layerId: null, + mode: 'create', + source: { + align: 'left', + color: '#000000', + content: '', + fontFamily: 'Inter', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + }, + startSource: null, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + }); + const tool = createTextTool(); + + down(tool, h.ctx, pointer(40, 60)); + + expect(h.openTextCreate).not.toHaveBeenCalled(); + expect(h.openTextEdit).not.toHaveBeenCalled(); + }); + + it('ignores a non-primary button press', () => { + const h = createHarness(makeDoc()); + const tool = createTextTool(); + + down(tool, h.ctx, pointer(40, 60, 2)); + + expect(h.openTextCreate).not.toHaveBeenCalled(); + }); +}); + +describe('text tool: teardown', () => { + it('cancels the session on a real deactivate but not on a temporary one', () => { + const h = createHarness(makeDoc()); + const tool = createTextTool(); + + tool.onDeactivate?.(h.ctx, { temporary: true }); + expect(h.cancelTextEdit).not.toHaveBeenCalled(); + + tool.onDeactivate?.(h.ctx); + expect(h.cancelTextEdit).toHaveBeenCalledTimes(1); + }); + + it('cancels the session on an Escape key command', () => { + const h = createHarness(makeDoc()); + const tool = createTextTool(); + + tool.onKeyCommand?.(h.ctx, 'cancel'); + expect(h.cancelTextEdit).toHaveBeenCalledTimes(1); + + // Apply is a no-op here (React owns the live content/commit). + tool.onKeyCommand?.(h.ctx, 'apply'); + expect(h.cancelTextEdit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/textTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/textTool.ts new file mode 100644 index 00000000000..ab0d7cad091 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/textTool.ts @@ -0,0 +1,132 @@ +/** + * The text tool: click to CREATE editable-forever text, or click an existing + * text layer to re-edit it. + * + * Interaction contract (CANVAS_PLAN Phase 6.2): + * - **Click on empty area** → open a CREATE-mode text-editing session at that + * document point (style seeded from the text options bar). Nothing is added to + * the document until the session commits (a single `addCanvasLayer`). + * - **Click on an existing text layer** (top-most, enabled, unlocked) → open an + * EDIT-mode session on it. Hit-testing inverts the layer transform and checks + * the point against the layer's rendered text-block rect (cache size, or the + * pure estimate before a cache exists). Locked/hidden text layers are skipped. + * - **Click while a session is open** → the pointer pipeline commits the open + * session engine-side (`maybeCommitModalSession` → `commitOpenTextSession`, + * reading the live portal content) and swallows the press BEFORE it reaches + * this tool, so `onPointerDown` below is never invoked for that press. A + * subsequent click then places/edits. The `textEditSession` guard here is a + * defensive backstop for a harness that routes the press through anyway. + * - **Commit** is engine-side on a canvas pointerdown (above); the portal's blur + * (focus lost to non-canvas UI) and `mod+enter` also commit. The live typed + * text is read from the portal only at commit time (no per-keystroke traffic). + * - **Escape** is handled by the focused contenteditable (cancels the session); + * a defocused-but-open session is cancelled by the engine's Escape chain + * (`handleEscape`: text → transform → deselect), not routed to this tool. + * - A **real** tool switch cancels the session (`onDeactivate`); a **temporary** + * modifier-hold switch (space→view / alt→colorPicker) preserves it, mirroring + * the transform tool. + * + * Text layers are not hit-testable by the move/transform tools (like shapes and + * gradients), so this tool owns its own text hit-test. + * + * Zero React, zero import-time side effects. + */ + +import type { Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerContract, CanvasLayerSourceContract } from '@workbench/types'; + +import { applyToPoint, invert } from '@workbench/canvas-engine/math/mat2d'; +import { estimateTextExtent } from '@workbench/canvas-engine/render/rasterizers/textRasterizer'; + +import type { Tool, ToolContext } from './tool'; + +import { layerMatrix } from './moveHitTest'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +type TextSource = Extract; +/** A text-sourced raster layer. */ +type TextLayer = Extract & { source: TextSource }; + +/** True when `layer` is an enabled, unlocked text layer (an edit-session candidate). */ +const isEditableTextLayer = (layer: CanvasLayerContract): layer is TextLayer => + layer.type === 'raster' && layer.source.type === 'text' && layer.isEnabled && !layer.isLocked; + +/** + * The rendered text-block size for hit-testing: the live cache surface size when + * one exists (the precise, measured extent), else the pure estimate (before the + * layer has been rasterized once). + */ +const textLayerSize = (layer: TextLayer, ctx: ToolContext): { width: number; height: number } => { + const cache = ctx.layers.get(layer.id); + if (cache) { + return { height: cache.surface.height, width: cache.surface.width }; + } + return estimateTextExtent(layer.source); +}; + +/** The top-most editable text layer whose rendered block contains `point` (document space), or `null`. */ +const topTextLayerAt = (doc: CanvasDocumentContractV2, point: Vec2, ctx: ToolContext): TextLayer | null => { + for (const layer of doc.layers) { + if (!isEditableTextLayer(layer)) { + continue; + } + const inverse = invert(layerMatrix(layer.transform)); + if (!inverse) { + continue; + } + const local = applyToPoint(inverse, point); + const size = textLayerSize(layer, ctx); + if (local.x >= 0 && local.x <= size.width && local.y >= 0 && local.y <= size.height) { + return layer; + } + } + return null; +}; + +/** Creates a fresh text tool. It holds no gesture state (a click opens a session and returns). */ +export const createTextTool = (): Tool => ({ + cursor: () => 'text', + id: 'text', + onDeactivate: (ctx, opts) => { + if (opts?.temporary) { + // A modifier-hold switch (space/alt) preserves the open session for + // `onActivate` to resume when the hold ends — like the transform tool. + return; + } + // A real tool switch cancels the session (in practice the contenteditable's + // blur has already committed by now; this is the safety teardown). + ctx.cancelTextEdit?.(); + }, + onKeyCommand: (ctx, command) => { + // Defensive backstop: the pipeline does not route 'cancel' to tools (the + // engine's `handleEscape` chain owns text/transform/deselect), so this only + // fires if a harness routes it directly. Cancel drops a defocused session; + // apply is a no-op here (only the portal holds the live content to commit). + if (command === 'cancel') { + ctx.cancelTextEdit?.(); + } + }, + onPointerDown: (ctx, input) => { + if ((input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + // Backstop: when a session is open the pipeline commits+swallows the press + // before it reaches this tool, so this branch is normally unreached. Guard + // anyway so a harness that routes the press through never opens a 2nd session. + if (ctx.stores.textEditSession.get()) { + return; + } + const doc = ctx.getDocument(); + if (!doc) { + return; + } + const hit = topTextLayerAt(doc, input.documentPoint, ctx); + if (hit) { + ctx.openTextEdit?.(hit.id); + } else { + ctx.openTextCreate?.(input.documentPoint); + } + }, +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/tool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/tool.ts new file mode 100644 index 00000000000..d0dc350f8c3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/tool.ts @@ -0,0 +1,221 @@ +/** + * The `Tool` seam: the engine routes normalized pointer/wheel input to whichever + * tool is active. Tools are pure interaction handlers — they read the viewport + * and mirrored document through the engine-provided {@link ToolContext} and + * request re-renders via `invalidate`. + * + * Navigation tools (view) never dispatch and never touch pixels. Painting tools + * (brush/eraser) reach the layer-cache surfaces and raster backend through the + * same context, dispatch at most once per gesture (auto-creating a paint layer + * on pointer-down when needed), and emit exactly one {@link StrokeCommittedEvent} + * on commit — persistence/history are wired to that event downstream, not here. + * + * Zero React, zero import-time side effects. + */ + +import type { EngineStores } from '@workbench/canvas-engine/engineStores'; +import type { CreatePath2D } from '@workbench/canvas-engine/freehand'; +import type { LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { OverlayCursor } from '@workbench/canvas-engine/render/overlayRenderer'; +import type { RasterBackend } from '@workbench/canvas-engine/render/raster'; +import type { InvalidatePayload } from '@workbench/canvas-engine/render/scheduler'; +import type { SamInteractionState, SamVisualInput } from '@workbench/canvas-engine/samInteraction'; +import type { SelectionCommit } from '@workbench/canvas-engine/selection/selectionState'; +import type { LayerTransform } from '@workbench/canvas-engine/transform/transformMath'; +import type { PlacedSurface, PointerInput, PointerModifiers, Rect, ToolId, Vec2 } from '@workbench/canvas-engine/types'; +import type { Viewport } from '@workbench/canvas-engine/viewport'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +/** + * Emitted once per completed brush/eraser gesture. Persistence (Task P2.2) and + * history (Task P2.3) subscribe via `engine.tools.onStrokeCommitted`. `beforeImageData` + * and `afterImageData` are both sized to `dirtyRect`, so an undo can restore the + * pre-stroke pixels and a redo can re-apply the post-stroke pixels cheaply. + */ +export interface StrokeCommittedEvent { + /** The layer that received the stroke. */ + layerId: string; + /** The painted region in document space (integer bounds, clamped to the document). */ + dirtyRect: Rect; + /** Cache pixels within `dirtyRect` before the stroke. */ + beforeImageData: ImageData; + /** Cache pixels within `dirtyRect` after the stroke. */ + afterImageData: ImageData; + /** Which tool produced the stroke. */ + tool: 'brush' | 'eraser'; + /** + * When the gesture auto-created its paint layer on pointer-down, the created + * layer contract (and where it was inserted). The engine composes this into + * the stroke's history entry so an undo removes BOTH the stroke and the + * now-empty auto-created layer (and a redo re-adds the layer + stroke). + * Absent for strokes painted into a pre-existing layer. + */ + createdLayer?: { layer: CanvasLayerContract; index: number }; +} + +export interface PixelEditPatch { + rect: Rect; + before: ImageData; + after: ImageData; +} + +export interface ControlPixelEditTransaction { + readonly layerId: string; + commitPatch(label: string, patch: PixelEditPatch): void; + commitStroke(event: StrokeCommittedEvent): void; + cancel(): void; +} + +/** + * A transient per-layer transform override the compositor/overlay read at render + * time (a live drag preview that never touches the mirror). The move tool sets + * only `x`/`y` (rotation/scale fall back to the committed transform); the + * transform tool sets the full transform so a scale/rotate preview renders. + */ +export interface LayerTransformOverride { + x: number; + y: number; + scaleX?: number; + scaleY?: number; + rotation?: number; +} + +/** Everything a tool is allowed to reach, injected by the engine. */ +export interface ToolContext { + /** The pan/zoom viewport. */ + viewport: Viewport; + /** The current mirrored document, or `null` when none is available. */ + getDocument(): CanvasDocumentContractV2 | null; + /** Requests a re-render for the given flags. */ + invalidate(payload: InvalidatePayload): void; + /** Reducer bridge. Painting tools use it for the single gesture-start `addCanvasLayer`. */ + dispatch(action: CanvasProjectMutation): void; + /** + * Records a structural document edit on the engine-owned canvas history: + * dispatches `forward` now, and an undo dispatches `inverse` / a redo + * re-dispatches `forward`. The move tool commits a layer nudge through this. + */ + commitStructural(label: string, forward: CanvasProjectMutation, inverse: CanvasProjectMutation): void; + /** + * Sets (or clears with `null`) a transient per-layer transform override the + * compositor and overlay read at render time — a live drag preview that never + * touches the mirror/document. Cleared on commit or cancel. + */ + setLayerTransformOverride(layerId: string, override: LayerTransformOverride | null): void; + /** + * Begins a transform session on `layerId` (captures its committed transform, + * shows the live preview). Provided by the engine; the transform tool calls it + * on activate / when a layer is clicked. Absent in minimal test harnesses. + */ + beginTransformSession?(layerId: string): void; + /** Prepares direct or transactional pixel editing for a selected control layer. */ + beginControlPixelEdit?(layerId: string): ControlPixelEditTransaction | null; + /** Updates the active transform session's live transform (drag or numeric edit). */ + updateTransformSession?(transform: LayerTransform): void; + /** + * Commits the active transform session: a param commit (image layers) or a + * pixel bake (paint layers), as ONE undoable entry. Then clears the session. + */ + applyTransform?(): void; + /** Cancels the active transform session (drops the preview, no dispatch). */ + cancelTransform?(): void; + /** + * Opens a CREATE-mode text-editing session at `docPoint` (no layer yet; the + * commit later dispatches one `addCanvasLayer`). Seeds style from the text + * options store. The text tool calls it on an empty-area click. Absent in + * minimal test harnesses. + */ + openTextCreate?(docPoint: Vec2): void; + /** + * Opens an EDIT-mode text-editing session on an existing text layer (captures + * its committed source for the undo inverse). The text tool calls it when a + * click hits a text layer. Absent in minimal test harnesses. + */ + openTextEdit?(layerId: string): void; + /** Cancels the active text-editing session (drops it, no dispatch). */ + cancelTextEdit?(): void; + /** The raster backend, for allocating scratch stroke surfaces. */ + backend: RasterBackend; + /** The per-layer raster cache; painting tools fill directly into a layer's surface. */ + layers: LayerCacheStore; + /** Builds a `Path2D` (node-safe seam; the engine passes `(d) => new Path2D(d)`). */ + createPath2D: CreatePath2D; + /** Mints a fresh layer id for an auto-created paint layer. */ + createLayerId(): string; + /** The transient engine stores (tool options live here). */ + stores: EngineStores; + /** Reads core visual Select Object interaction state without depending on application sessions. */ + getSamInteraction?(): SamInteractionState | null; + /** Sets (or clears) the brush cursor ring drawn on the overlay. */ + setOverlayCursor(cursor: OverlayCursor | null): void; + /** + * Re-evaluates the active tool's CSS cursor and applies it to the input + * element. A tool calls this when its `cursor(ctx)` result changes off a plain + * pointer-move (e.g. the bbox tool switching to a resize cursor while hovering a + * handle) — pointer-move does not otherwise refresh the cursor. + */ + updateCursor(): void; + /** Emits a completed-stroke event to `engine.tools.onStrokeCommitted` subscribers. */ + emitStrokeCommitted(event: StrokeCommittedEvent): void; + /** Bumps a layer's cache version (without marking it stale) after a direct paint, and recomposites. */ + notifyLayerPainted(layerId: string): void; + /** + * Commits a lasso path to the engine's transient selection (boolean op applied + * to the mask). Provided by the engine; the lasso tool calls it on pointer-up. + * Absent in minimal test harnesses. + */ + commitSelection?(commit: SelectionCommit): void; + /** + * The current selection mask as a placed surface (alpha 255 inside) in document + * space — the mask is bounded to the selection extent, so its `rect` records + * where it sits. `null` when there is no selection. Painting tools read it ONCE + * on pointer-down to clip the stroke; a `null` result keeps the zero-overhead + * hot path. Absent in minimal test harnesses. + */ + getSelectionMask?(): PlacedSurface | null; + /** Updates visual SAM input for the active engine-owned Select Object session. */ + updateSamInput?(input: SamVisualInput): void; +} + +/** + * Why a tool is being (de)activated, passed by the engine's `setTool` so a + * session-bearing tool (transform) can tell a temporary modifier-hold switch + * (space→view, alt→colorPicker; the pointer pipeline restores the prior tool + * on release) apart from a REAL tool switch. A temp switch must not tear down + * an in-progress session — only a real switch (or dispose) does. + */ +export interface ToolActivationOptions { + /** True for a pipeline modifier-hold switch (and its matching restore); absent/false for a real switch. */ + temporary?: boolean; +} + +/** A stateless-to-the-engine interaction handler. Implementations may hold private drag state. */ +export interface Tool { + readonly id: ToolId; + /** Called when the tool becomes active. */ + onActivate?(ctx: ToolContext, opts?: ToolActivationOptions): void; + /** Called when the tool is deactivated (also on engine dispose). */ + onDeactivate?(ctx: ToolContext, opts?: ToolActivationOptions): void; + onPointerDown?(ctx: ToolContext, input: PointerInput): void; + /** + * A pointer move. `batch` carries the coalesced samples for this move event + * (always at least one; its last element equals `input`); tools that paint + * consume the whole batch, navigation tools use only `input`. + */ + onPointerMove?(ctx: ToolContext, input: PointerInput, batch: readonly PointerInput[]): void; + onPointerUp?(ctx: ToolContext, input: PointerInput): void; + /** The active gesture was cancelled (Esc, pointercancel, focus loss). */ + onPointerCancel?(ctx: ToolContext): void; + /** + * A session-level key command routed from the pointer pipeline: Enter → + * `'apply'`, Escape → `'cancel'`. Tools with a multi-gesture session (transform) + * use it to commit/abort; other tools ignore it. Escape also runs the normal + * gesture cancel independently. + */ + onKeyCommand?(ctx: ToolContext, command: 'apply' | 'cancel'): void; + /** Wheel over the canvas; `screenAnchor` is the CSS-pixel cursor position. */ + onWheel?(ctx: ToolContext, deltaY: number, screenAnchor: { x: number; y: number }, modifiers: PointerModifiers): void; + /** The CSS cursor to show while this tool is active. */ + cursor?(ctx: ToolContext): string; +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.test.ts new file mode 100644 index 00000000000..69dff9b25df --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.test.ts @@ -0,0 +1,440 @@ +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { LayerTransform } from '@workbench/canvas-engine/transform/transformMath'; +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { TRANSFORM_ROTATE_NUB_PX, transformOverlayGeometry } from '@workbench/canvas-engine/transform/transformMath'; +import { describe, expect, it, vi } from 'vitest'; + +import { createTransformTool } from './transformTool'; + +const imageLayer = ( + id: string, + opts: { x?: number; y?: number; width?: number; height?: number; isLocked?: boolean; isEnabled?: boolean } = {} +): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: opts.isEnabled ?? true, + isLocked: opts.isLocked ?? false, + name: id, + opacity: 1, + source: { image: { height: opts.height ?? 100, imageName: id, width: opts.width ?? 100 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: opts.x ?? 0, y: opts.y ?? 0 }, + type: 'raster', +}); + +const makeDoc = (layers: CanvasLayerContract[], selectedLayerId: string | null): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 200, width: 200, x: 0, y: 0 }, + height: 200, + layers, + selectedLayerId, + version: 2, + width: 200, +}); + +const pointer = ( + x: number, + y: number, + opts: { shift?: boolean; alt?: boolean; buttons?: number } = {} +): PointerInput => ({ + buttons: opts.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: opts.alt ?? false, ctrl: false, meta: false, shift: opts.shift ?? false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +/** A pointer input at an explicit SCREEN point, with the doc point derived at `zoom`. */ +const pointerAtScreen = (screen: Vec2, zoom: number): PointerInput => ({ + buttons: 1, + documentPoint: { x: screen.x / zoom, y: screen.y / zoom }, + modifiers: { alt: false, ctrl: false, meta: false, shift: false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: screen, + timeStamp: 0, +}); + +/** The screen-space tip of the drawn rotation nub for `transform` at `zoom`. */ +const nubTipScreen = ( + transform: LayerTransform, + sz: { x: number; y: number; width: number; height: number }, + zoom: number +): Vec2 => { + const geo = transformOverlayGeometry(transform, sz); + const toScreen = (p: Vec2) => ({ x: zoom * p.x, y: zoom * p.y }); + const a = toScreen(geo.rotationAnchor); + const c = toScreen(geo.center); + const dx = a.x - c.x; + const dy = a.y - c.y; + const len = Math.hypot(dx, dy) || 1; + return { x: a.x + (dx / len) * TRANSFORM_ROTATE_NUB_PX, y: a.y + (dy / len) * TRANSFORM_ROTATE_NUB_PX }; +}; + +/** Rotates `p` about `pivot` by `rad` (screen space). */ +const rotateAbout = (p: Vec2, pivot: Vec2, rad: number): Vec2 => { + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const dx = p.x - pivot.x; + const dy = p.y - pivot.y; + return { x: pivot.x + cos * dx - sin * dy, y: pivot.y + sin * dx + cos * dy }; +}; + +interface Harness { + ctx: ToolContext; + applyCount: () => number; + session: () => ReturnType['transformSession']['get']>; + overrides: { layerId: string; override: unknown }[]; +} + +/** + * A ToolContext whose transform-session seams mutate a real `transformSession` + * store (mirroring the engine), so the tool's reads reflect its own writes across + * a down→move→up drag. The viewport projects document→screen 1:1. + */ +const createHarness = (doc: CanvasDocumentContractV2, zoom = 1): Harness => { + const stores = createEngineStores(); + const overrides: { layerId: string; override: unknown }[] = []; + const state = { applyCount: 0 }; + + const beginTransformSession = (layerId: string): void => { + const layer = doc.layers.find((entry) => entry.id === layerId); + if (!layer) { + return; + } + const start: LayerTransform = { ...layer.transform }; + stores.transformSession.set({ layerId, startTransform: start, transform: start }); + overrides.push({ layerId, override: start }); + }; + const updateTransformSession = (transform: LayerTransform): void => { + const session = stores.transformSession.get(); + if (!session) { + return; + } + stores.transformSession.set({ ...session, transform }); + overrides.push({ layerId: session.layerId, override: transform }); + }; + const cancelTransform = (): void => { + const session = stores.transformSession.get(); + if (session) { + overrides.push({ layerId: session.layerId, override: null }); + } + stores.transformSession.set(null); + }; + + const ctx: ToolContext = { + applyTransform: () => { + state.applyCount += 1; + }, + backend: null as never, + beginTransformSession, + cancelTransform, + commitStructural: vi.fn(), + createLayerId: () => 'x', + createPath2D: (d) => ({ d }) as unknown as Path2D, + dispatch: vi.fn(), + emitStrokeCommitted: vi.fn(), + getDocument: () => doc, + invalidate: vi.fn(), + layers: null as never, + notifyLayerPainted: vi.fn(), + setLayerTransformOverride: vi.fn(), + setOverlayCursor: vi.fn(), + stores, + updateCursor: vi.fn(), + updateTransformSession, + viewport: { + documentToScreen: (p: Vec2) => ({ x: zoom * p.x, y: zoom * p.y }), + screenToDocument: (p: Vec2) => ({ x: p.x / zoom, y: p.y / zoom }), + } as never, + }; + + return { + applyCount: () => state.applyCount, + ctx, + overrides, + session: () => stores.transformSession.get(), + }; +}; + +const activate = (t: Tool, ctx: ToolContext): void => t.onActivate?.(ctx); +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerMove?.(ctx, i, [i]); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); + +describe('transform tool: session lifecycle', () => { + it('opens a session on the selected eligible layer when activated', () => { + const doc = makeDoc([imageLayer('a')], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + + activate(tool, h.ctx); + + const s = h.session(); + expect(s?.layerId).toBe('a'); + expect(s?.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }); + }); + + it('opens no session on a locked selected layer', () => { + const doc = makeDoc([imageLayer('a', { isLocked: true })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + + activate(tool, h.ctx); + + expect(h.session()).toBeNull(); + }); + + it('opens no session on a hidden selected layer', () => { + const doc = makeDoc([imageLayer('a', { isEnabled: false })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + + activate(tool, h.ctx); + + expect(h.session()).toBeNull(); + }); + + it('clicking a layer with no session starts a session (move gesture)', () => { + const doc = makeDoc([imageLayer('a', { width: 100, height: 100 })], null); + const h = createHarness(doc); + const tool = createTransformTool(); + + down(tool, h.ctx, pointer(50, 50)); + expect(h.session()?.layerId).toBe('a'); + }); +}); + +describe('transform tool: gestures', () => { + it('scales via a corner handle drag (updates the session, no dispatch)', () => { + const doc = makeDoc([imageLayer('a', { width: 100, height: 100 })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + + // se corner is at doc (100,100); drag it to (150,150). + down(tool, h.ctx, pointer(100, 100)); + move(tool, h.ctx, pointer(150, 150)); + up(tool, h.ctx, pointer(150, 150)); + + const s = h.session(); + expect(s?.transform.scaleX).toBeCloseTo(1.5, 5); + expect(s?.transform.scaleY).toBeCloseTo(1.5, 5); + // No structural dispatch — the session holds the preview until Apply. + expect(h.ctx.commitStructural).not.toHaveBeenCalled(); + }); + + it('moves via an interior drag', () => { + const doc = makeDoc([imageLayer('a', { width: 100, height: 100 })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + + down(tool, h.ctx, pointer(50, 50)); + move(tool, h.ctx, pointer(70, 65)); + up(tool, h.ctx, pointer(70, 65)); + + const s = h.session(); + expect(s?.transform.x).toBeCloseTo(20, 5); + expect(s?.transform.y).toBeCloseTo(15, 5); + }); + + it('rotates via a corner rotate zone drag', () => { + const doc = makeDoc([imageLayer('a', { width: 100, height: 100 })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + + // Just outside the se corner (100,100) is a rotate zone. + down(tool, h.ctx, pointer(112, 112)); + move(tool, h.ctx, pointer(100, 130)); + up(tool, h.ctx, pointer(100, 130)); + + const s = h.session(); + expect(s?.transform.rotation).not.toBe(0); + }); + + it('ignores a sub-threshold drag (no session change)', () => { + const doc = makeDoc([imageLayer('a', { width: 100, height: 100 })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + const startOverrides = h.overrides.length; + + down(tool, h.ctx, pointer(100, 100)); + move(tool, h.ctx, pointer(101, 101)); + up(tool, h.ctx, pointer(101, 101)); + + // No update beyond the initial begin override. + expect(h.overrides.length).toBe(startOverrides); + expect(h.session()?.transform.scaleX).toBe(1); + }); +}); + +describe('transform tool: rotation nub (regression — a nub press must rotate, not reset)', () => { + const transformed: LayerTransform = { rotation: 0.4, scaleX: 1.6, scaleY: 1.3, x: 30, y: 20 }; + const layerSize = { height: 100, width: 100, x: 0, y: 0 }; + + const run = (zoom: number): void => { + const layer: CanvasLayerContract = { ...imageLayer('a'), transform: transformed }; + const doc = makeDoc([layer], 'a'); + const h = createHarness(doc, zoom); + const tool = createTransformTool(); + activate(tool, h.ctx); + + const startTransform = h.session()?.transform; + expect(startTransform).toEqual(transformed); + const overridesBefore = h.overrides.length; + + // Pointer-down EXACTLY on the drawn rotation nub (above the top edge). + const tip = nubTipScreen(transformed, layerSize, zoom); + down(tool, h.ctx, pointerAtScreen(tip, zoom)); + + // (a) Gesture start must NOT touch the session/override values. The bug read + // the nub press as off-frame and re-opened the session, resetting its live + // transform back to the committed one. + expect(h.session()?.transform).toEqual(startTransform); + expect(h.overrides.length).toBe(overridesBefore); + + // (b) A subsequent move begins a ROTATION: rotation changes by the swept + // angle, scale is untouched (not a move/scale/reset). + const geo = transformOverlayGeometry(transformed, layerSize); + const centerScreen: Vec2 = { x: zoom * geo.center.x, y: zoom * geo.center.y }; + const moved = rotateAbout(tip, centerScreen, 0.5); + move(tool, h.ctx, pointerAtScreen(moved, zoom)); + + const after = h.session()?.transform; + expect(after).toBeDefined(); + expect(after?.scaleX).toBeCloseTo(transformed.scaleX, 6); + expect(after?.scaleY).toBeCloseTo(transformed.scaleY, 6); + expect(after?.rotation).toBeCloseTo(transformed.rotation + 0.5, 6); + }; + + it('rotates (does not reset) at zoom 1', () => run(1)); + it('rotates (does not reset) at a non-1 zoom', () => run(2.5)); +}); + +describe('transform tool: apply / cancel', () => { + it('Enter applies the session', () => { + const doc = makeDoc([imageLayer('a')], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + + tool.onKeyCommand?.(h.ctx, 'apply'); + + // The engine apply seam was invoked exactly once. + expect(h.applyCount()).toBe(1); + }); + + it('Escape cancels the session', () => { + const doc = makeDoc([imageLayer('a')], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + expect(h.session()).not.toBeNull(); + + tool.onKeyCommand?.(h.ctx, 'cancel'); + + expect(h.session()).toBeNull(); + }); + + it('tool switch (deactivate) cancels the session', () => { + const doc = makeDoc([imageLayer('a')], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + + tool.onDeactivate?.(h.ctx); + + expect(h.session()).toBeNull(); + }); + + it('pointercancel reverts the drag but keeps the session', () => { + const doc = makeDoc([imageLayer('a', { width: 100, height: 100 })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + + down(tool, h.ctx, pointer(100, 100)); + move(tool, h.ctx, pointer(160, 160)); + expect(h.session()?.transform.scaleX).toBeGreaterThan(1); + + tool.onPointerCancel?.(h.ctx); + + // Session persists, reverted to the start transform. + const s = h.session(); + expect(s).not.toBeNull(); + expect(s?.transform.scaleX).toBe(1); + }); + + it('Enter mid-drag no-ops: the gesture continues (a further move still updates the session) and pointer-up still works', () => { + const doc = makeDoc([imageLayer('a', { width: 100, height: 100 })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + + // se corner drag, past the threshold — a gesture is now in progress and + // holds (conceptually) pointer capture. + down(tool, h.ctx, pointer(100, 100)); + move(tool, h.ctx, pointer(120, 120)); + const midDrag = h.session()?.transform.scaleX; + expect(midDrag).toBeGreaterThan(1); + + tool.onKeyCommand?.(h.ctx, 'apply'); + + // No-op: the engine apply seam was NOT invoked (unlike the "Enter applies + // the session" test above, which has no live gesture). + expect(h.applyCount()).toBe(0); + + // The gesture is still alive — it did not silently freeze mid-drag. + move(tool, h.ctx, pointer(150, 150)); + expect(h.session()?.transform.scaleX).toBeGreaterThan(midDrag!); + + // Pointer-up still ends the gesture normally, keeping the session. + up(tool, h.ctx, pointer(150, 150)); + expect(h.session()).not.toBeNull(); + }); +}); + +describe('transform tool: temp-tool switch (space/alt hold)', () => { + it('a temporary deactivate preserves the session; the matching temporary activate leaves it untouched', () => { + const doc = makeDoc([imageLayer('a', { x: 5, y: 5 })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + h.ctx.updateTransformSession?.({ rotation: 0, scaleX: 2, scaleY: 1, x: 40, y: 5 }); + const edited = h.session()?.transform; + + // Space down: a temporary deactivate must not cancel the session. + tool.onDeactivate?.(h.ctx, { temporary: true }); + expect(h.session()?.transform).toEqual(edited); + + // Space up: a temporary activate must not re-open the session from the + // current selection (which would stomp the preserved edit with the + // layer's committed transform). + tool.onActivate?.(h.ctx, { temporary: true }); + expect(h.session()?.transform).toEqual(edited); + }); + + it('a temporary activate does not resurrect a session the engine already cancelled while held (e.g. its layer was deleted)', () => { + const doc = makeDoc([imageLayer('a')], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + expect(h.session()).not.toBeNull(); + + tool.onDeactivate?.(h.ctx, { temporary: true }); + // Simulates the engine's layer-change teardown (Task 26 finding #3) + // cancelling the session out-of-band while temp-switched away. + h.ctx.cancelTransform?.(); + expect(h.session()).toBeNull(); + + tool.onActivate?.(h.ctx, { temporary: true }); + expect(h.session()).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.ts new file mode 100644 index 00000000000..3833123d0cd --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.ts @@ -0,0 +1,297 @@ +/** + * The transform tool: an interactive scale/rotate/move SESSION on a single layer. + * + * Interaction contract (CANVAS_PLAN Phase 5): + * - **Session**: selecting the tool with an eligible layer selected (or clicking a + * layer) captures its committed transform and opens a session. The live preview + * flows through the engine's transform-override channel; a `transformSession` + * store exposes the layer id + live transform so the numeric options bar can + * render and edit it. The session survives multiple gestures — drag handles, + * drag to rotate/move, adjust numerics — until **Apply** or **Cancel**. + * - **Gestures** (each a fresh pointer drag on the session's frame): a scale + * handle scales about the opposite handle (alt = center, shift = uniform on + * corners); a corner rotate zone rotates about the center (shift = 15° snap); + * the interior moves (shift = axis constrain). Pointer-move only updates the + * session preview — it never dispatches. + * - **Apply** (`enter` / options button): the engine commits — a param edit for + * image layers, a pixel bake for paint layers — as ONE undoable entry. + * - **Cancel** (`esc` / options button / a REAL tool switch): drops the + * preview, no dispatch. A mid-gesture pointercancel reverts just that drag, + * keeping the session; Escape aborts the whole session. A TEMPORARY tool + * switch (space/alt modifier-hold) is not a cancel — the session and its + * preview survive the hold and resume when it ends (see `onActivate`/ + * `onDeactivate`'s `opts.temporary`). If the session's layer is deleted + * while held, the engine's layer-change teardown cancels it regardless of + * which tool is active. + * + * Locked/hidden layers get no session (same guard as the move tool). Zero React, + * zero import-time side effects. + */ + +import type { LayerTransform, TransformRect, TransformTarget } from '@workbench/canvas-engine/transform/transformMath'; +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasLayerContract } from '@workbench/types'; + +import { + applyMove, + applyRotate, + applyScale, + resizeCursorForHandle, + transformTargetAt, +} from '@workbench/canvas-engine/transform/transformMath'; + +import type { Tool, ToolContext } from './tool'; + +import { hittableLayerRect, topLayerAt } from './moveHitTest'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** Screen-space distance (CSS px) the pointer must travel before a press becomes a drag. */ +export const TRANSFORM_DRAG_THRESHOLD_PX = 3; + +interface GestureState { + target: TransformTarget; + /** The session transform captured at gesture start (revert target for cancel). */ + startTransform: LayerTransform; + startPointerDoc: Vec2; + startScreen: Vec2; + /** The session layer's local content rect (off-origin aware). */ + rect: TransformRect; + /** The cursor held for the duration of this gesture. */ + cursor: string; + moved: boolean; +} + +/** The cursor for a hovered/grabbed target, given the layer's current transform. */ +const cursorForTarget = (transform: LayerTransform, target: TransformTarget): string => { + if (target.kind === 'scale') { + return resizeCursorForHandle(transform, target.handle); + } + return target.kind === 'rotate' ? 'grab' : 'move'; +}; + +/** Creates a fresh transform tool with its own session/gesture state. */ +export const createTransformTool = (): Tool => { + let gesture: GestureState | null = null; + // The cursor for the target under the pointer while idle (session but no drag). + let hoverCursor: string | null = null; + + const isEligible = (layer: CanvasLayerContract, doc: NonNullable>): boolean => + // Masks are MOVE-able (legacy parity) but not transform-able in this phase: + // `applyTransform` has no mask bake path, so a transform session on a mask + // would preview then no-op on Apply. Exclude them until that lands (Phase 7+). + layer.isEnabled && + !layer.isLocked && + layer.type !== 'inpaint_mask' && + layer.type !== 'regional_guidance' && + hittableLayerRect(layer, doc) !== null; + + /** Hit-tests the active session's frame at a screen point (or `null`). */ + const targetAt = (ctx: ToolContext, screenPoint: Vec2): TransformTarget | null => { + const session = ctx.stores.transformSession.get(); + const doc = ctx.getDocument(); + if (!session || !doc) { + return null; + } + const layer = doc.layers.find((candidate) => candidate.id === session.layerId); + const rect = layer ? hittableLayerRect(layer, doc) : null; + if (!rect) { + return null; + } + return transformTargetAt({ + point: screenPoint, + rect, + toScreen: (p) => ctx.viewport.documentToScreen(p), + transform: session.transform, + }); + }; + + const nextTransform = (state: GestureState, input: PointerInput): LayerTransform => { + const delta: Vec2 = { + x: input.documentPoint.x - state.startPointerDoc.x, + y: input.documentPoint.y - state.startPointerDoc.y, + }; + switch (state.target.kind) { + case 'move': + return applyMove(state.startTransform, delta, input.modifiers.shift); + case 'scale': + return applyScale({ + alt: input.modifiers.alt, + handle: state.target.handle, + pointerDoc: input.documentPoint, + shift: input.modifiers.shift, + rect: state.rect, + start: state.startTransform, + startPointerDoc: state.startPointerDoc, + }); + case 'rotate': + return applyRotate({ + pointerDoc: input.documentPoint, + shift: input.modifiers.shift, + rect: state.rect, + start: state.startTransform, + startPointerDoc: state.startPointerDoc, + }); + } + }; + + const endGesture = (): void => { + gesture = null; + }; + + return { + cursor: () => { + if (gesture) { + return gesture.cursor; + } + return hoverCursor ?? 'default'; + }, + id: 'transform', + onActivate: (ctx, opts) => { + if (opts?.temporary) { + // Resuming from a modifier-hold switch (space→view, alt→colorPicker): + // `onDeactivate` preserved the session (and its preview override) + // across the hold, so there is nothing to (re)open here. Re-opening + // from the current selection would stomp the live preview with the + // layer's committed transform, discarding accumulated drags/numeric + // edits. If the session's layer vanished mid-hold, the engine's + // layer-change teardown already cancelled it — leave that alone too. + return; + } + // Entering the tool on an eligible selected layer opens a session on it. + const doc = ctx.getDocument(); + const selectedId = doc?.selectedLayerId; + const selected = selectedId ? doc?.layers.find((layer) => layer.id === selectedId) : undefined; + if (doc && selected && isEligible(selected, doc)) { + ctx.beginTransformSession?.(selected.id); + } + }, + onDeactivate: (ctx, opts) => { + hoverCursor = null; + if (opts?.temporary) { + // A modifier-hold switch (space/alt) must not discard an in-progress + // session: the pipeline already suppresses temp switches mid-gesture, + // so `gesture` is guaranteed null here — this only clears the idle + // hover cursor. The session + preview override are left for + // `onActivate` to resume when the hold ends. A REAL tool switch (below) + // still cancels. + endGesture(); + return; + } + // A real tool switch mid-session cancels it (drops the preview, no dispatch). + endGesture(); + ctx.cancelTransform?.(); + }, + onKeyCommand: (ctx, command) => { + if (gesture) { + // A live drag holds pointer capture (its own pointerup/pointercancel + // will end it); applying or cancelling now would tear the gesture down + // out from under the still-open pointer session, freezing the preview + // mid-drag. No-op instead — mirrors `applyTransform`'s own mid-gesture + // guard (`pipeline.isGestureActive()`) for the same reason. + return; + } + if (command === 'apply') { + ctx.applyTransform?.(); + } else { + ctx.cancelTransform?.(); + } + }, + onPointerCancel: (ctx) => { + // Revert just this drag (keep the session); Escape's session cancel is separate. + if (gesture) { + const session = ctx.stores.transformSession.get(); + if (session) { + ctx.updateTransformSession?.(gesture.startTransform); + } + endGesture(); + ctx.invalidate({ overlay: true }); + } + }, + onPointerDown: (ctx, input) => { + if (gesture || (input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + const doc = ctx.getDocument(); + if (!doc) { + return; + } + const session = ctx.stores.transformSession.get(); + + // 1) An open session: a press on its frame starts a scale/rotate/move gesture. + if (session) { + const layer = doc.layers.find((candidate) => candidate.id === session.layerId); + const rect = layer ? hittableLayerRect(layer, doc) : null; + const target = rect ? targetAt(ctx, input.screenPoint) : null; + if (rect && target) { + gesture = { + cursor: target.kind === 'rotate' ? 'grabbing' : cursorForTarget(session.transform, target), + moved: false, + rect, + startPointerDoc: input.documentPoint, + startScreen: input.screenPoint, + startTransform: session.transform, + target, + }; + return; + } + } + + // 2) No session, or a press off the session frame: adopt the top-most eligible + // layer under the pointer and start a move gesture on it. Empty space is a + // no-op (the current session, if any, persists). + const hit = topLayerAt(doc, input.documentPoint, (layer) => isEligible(layer, doc)); + if (!hit) { + return; + } + const rect = hittableLayerRect(hit, doc); + if (!rect) { + return; + } + ctx.beginTransformSession?.(hit.id); + gesture = { + cursor: 'move', + moved: false, + rect, + startPointerDoc: input.documentPoint, + startScreen: input.screenPoint, + startTransform: hit.transform, + target: { kind: 'move' }, + }; + }, + onPointerMove: (ctx, input) => { + if (gesture) { + if (!gesture.moved) { + const dxs = input.screenPoint.x - gesture.startScreen.x; + const dys = input.screenPoint.y - gesture.startScreen.y; + if (Math.hypot(dxs, dys) < TRANSFORM_DRAG_THRESHOLD_PX) { + return; + } + gesture.moved = true; + } + ctx.updateTransformSession?.(nextTransform(gesture, input)); + return; + } + // Idle hover over a session frame: reflect the target under the pointer. + const session = ctx.stores.transformSession.get(); + if (!session) { + if (hoverCursor !== null) { + hoverCursor = null; + ctx.updateCursor(); + } + return; + } + const target = targetAt(ctx, input.screenPoint); + const cursor = target ? cursorForTarget(session.transform, target) : 'default'; + if (cursor !== hoverCursor) { + hoverCursor = cursor; + ctx.updateCursor(); + } + }, + onPointerUp: () => { + // The session's live transform already reflects the drag; keep the session. + endGesture(); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/viewTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/viewTool.ts new file mode 100644 index 00000000000..515e39a2720 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/viewTool.ts @@ -0,0 +1,59 @@ +/** + * The view tool: pan the canvas by dragging, zoom with the wheel. It is the + * default tool and the one the engine temporarily swaps in while space is held. + * All navigation flows through the viewport, so it never mutates the document + * and never dispatches. + * + * Each engine builds its own instance via {@link createViewTool} so the private + * drag state is per-engine (never module-global). Zero React, zero import-time + * side effects. + */ + +import type { PointerInput, PointerModifiers, Vec2 } from '@workbench/canvas-engine/types'; + +import type { Tool, ToolContext } from './tool'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** Creates a fresh view tool with its own drag state. */ +export const createViewTool = (): Tool => { + let panning = false; + let lastScreen: Vec2 | null = null; + + const begin = (input: PointerInput): void => { + // Only the primary button pans; secondary/middle are handled by the engine. + if ((input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + panning = true; + lastScreen = input.screenPoint; + }; + + const move = (ctx: ToolContext, input: PointerInput): void => { + if (!panning || !lastScreen) { + return; + } + ctx.viewport.panBy({ x: input.screenPoint.x - lastScreen.x, y: input.screenPoint.y - lastScreen.y }); + lastScreen = input.screenPoint; + ctx.invalidate({ view: true }); + }; + + const end = (): void => { + panning = false; + lastScreen = null; + }; + + return { + cursor: () => (panning ? 'grabbing' : 'grab'), + id: 'view', + onDeactivate: end, + onPointerDown: (_ctx, input) => begin(input), + onPointerMove: move, + onPointerUp: end, + onWheel: (ctx: ToolContext, deltaY: number, screenAnchor: Vec2, _modifiers: PointerModifiers) => { + ctx.viewport.wheelZoom(deltaY, screenAnchor); + ctx.invalidate({ view: true }); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/transform/transformMath.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/transform/transformMath.test.ts new file mode 100644 index 00000000000..4a4951d355b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/transform/transformMath.test.ts @@ -0,0 +1,449 @@ +import { applyToPoint } from '@workbench/canvas-engine/math/mat2d'; +import { describe, expect, it } from 'vitest'; + +import type { LayerTransform, TransformRect } from './transformMath'; + +import { + applyMove, + applyRotate, + applyScale, + bakeMatrix, + IDENTITY_TRANSFORM, + layerTransformMatrix, + localHandlePoint, + MIN_ABS_SCALE, + resizeCursorForHandle, + ROTATE_SNAP_RAD, + snapRotation, + TRANSFORM_ROTATE_NUB_PX, + transformOverlayGeometry, + transformTargetAt, +} from './transformMath'; + +/** The screen-space tip of the drawn rotation nub for `transform` at `zoom`. */ +const nubTip = (transform: LayerTransform, sz: TransformRect, zoom: number): { x: number; y: number } => { + const geo = transformOverlayGeometry(transform, sz); + const toScreen = (p: { x: number; y: number }) => ({ x: zoom * p.x, y: zoom * p.y }); + const a = toScreen(geo.rotationAnchor); + const c = toScreen(geo.center); + const dx = a.x - c.x; + const dy = a.y - c.y; + const len = Math.hypot(dx, dy) || 1; + return { x: a.x + (dx / len) * TRANSFORM_ROTATE_NUB_PX, y: a.y + (dy / len) * TRANSFORM_ROTATE_NUB_PX }; +}; + +const identity: LayerTransform = { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }; +const rect: TransformRect = { height: 100, width: 100, x: 0, y: 0 }; + +/** A zoom-only screen projection (no rotation/flip), matching the real viewport. */ +const scaleToScreen = + (zoom: number, panX = 0, panY = 0) => + (p: { x: number; y: number }) => ({ x: zoom * p.x + panX, y: zoom * p.y + panY }); + +const identityScreen = scaleToScreen(1); + +const close = (a: number, b: number, eps = 1e-6): boolean => Math.abs(a - b) <= eps; + +describe('localHandlePoint', () => { + it('places corners and edge midpoints on the native rect', () => { + expect(localHandlePoint(rect, 'nw')).toEqual({ x: 0, y: 0 }); + expect(localHandlePoint(rect, 'se')).toEqual({ x: 100, y: 100 }); + expect(localHandlePoint(rect, 'e')).toEqual({ x: 100, y: 50 }); + expect(localHandlePoint(rect, 'n')).toEqual({ x: 50, y: 0 }); + }); + + it('honors an off-origin content rect (content-sized paint layers)', () => { + // A paint layer whose bitmap sits at offset (10, 20): every handle shifts + // by the origin — the frame must wrap the pixels, not [0,w]×[0,h]. + const offOrigin: TransformRect = { height: 40, width: 60, x: 10, y: 20 }; + expect(localHandlePoint(offOrigin, 'nw')).toEqual({ x: 10, y: 20 }); + expect(localHandlePoint(offOrigin, 'se')).toEqual({ x: 70, y: 60 }); + expect(localHandlePoint(offOrigin, 'e')).toEqual({ x: 70, y: 40 }); + }); +}); + +describe('off-origin content rect (content-sized layers)', () => { + const offOrigin: TransformRect = { height: 40, width: 60, x: 10, y: 20 }; + + it('transformOverlayGeometry wraps the off-origin rect at identity', () => { + const geo = transformOverlayGeometry(identity, offOrigin); + expect(geo.corners).toEqual([ + { x: 10, y: 20 }, + { x: 70, y: 20 }, + { x: 70, y: 60 }, + { x: 10, y: 60 }, + ]); + expect(geo.center).toEqual({ x: 40, y: 40 }); + }); + + it('transformTargetAt hit-tests the shifted frame (interior + corner)', () => { + const inside = transformTargetAt({ + point: { x: 40, y: 40 }, + rect: offOrigin, + toScreen: identityScreen, + transform: identity, + }); + expect(inside).toEqual({ kind: 'move' }); + const corner = transformTargetAt({ + point: { x: 70, y: 60 }, + rect: offOrigin, + toScreen: identityScreen, + transform: identity, + }); + expect(corner).toEqual({ handle: 'se', kind: 'scale' }); + // A point far from the shifted frame (outside its interior, handles, and + // rotate zones) is NOT a target, even though an (incorrect) origin-anchored + // frame would sit in that direction. + const stale = transformTargetAt({ + point: { x: -40, y: -40 }, + rect: offOrigin, + toScreen: identityScreen, + transform: identity, + }); + expect(stale).toBeNull(); + }); + + it('applyScale keeps the opposite (off-origin) anchor fixed in document space', () => { + // Drag the se handle from (70,60) to (130,100): 2× in both axes, anchored at nw. + const next = applyScale({ + alt: false, + handle: 'se', + pointerDoc: { x: 130, y: 100 }, + rect: offOrigin, + shift: false, + start: identity, + startPointerDoc: { x: 70, y: 60 }, + }); + expect(next.scaleX).toBeCloseTo(2, 6); + expect(next.scaleY).toBeCloseTo(2, 6); + // The nw corner (local 10,20) must not move: T = anchorDoc − S·anchorLocal. + const anchorAfter = applyToPoint(layerTransformMatrix(next), { x: 10, y: 20 }); + expect(anchorAfter.x).toBeCloseTo(10, 6); + expect(anchorAfter.y).toBeCloseTo(20, 6); + }); + + it('applyRotate pivots about the off-origin rect center', () => { + const next = applyRotate({ + pointerDoc: { x: 40, y: 100 }, + rect: offOrigin, + shift: false, + start: identity, + // Start due east of the center (40,40); end due south → +90°. + startPointerDoc: { x: 100, y: 40 }, + }); + expect(next.rotation).toBeCloseTo(Math.PI / 2, 6); + const centerAfter = applyToPoint(layerTransformMatrix(next), { x: 40, y: 40 }); + expect(centerAfter.x).toBeCloseTo(40, 6); + expect(centerAfter.y).toBeCloseTo(40, 6); + }); +}); + +describe('transformTargetAt', () => { + it('hits a corner scale handle at zoom 1', () => { + const target = transformTargetAt({ + point: { x: 100, y: 100 }, + rect, + toScreen: identityScreen, + transform: identity, + }); + expect(target).toEqual({ handle: 'se', kind: 'scale' }); + }); + + it('reports the interior as move', () => { + const target = transformTargetAt({ point: { x: 50, y: 50 }, rect, toScreen: identityScreen, transform: identity }); + expect(target).toEqual({ kind: 'move' }); + }); + + it('reports a rotate zone just outside a corner', () => { + const target = transformTargetAt({ + point: { x: 112, y: 112 }, + rect, + toScreen: identityScreen, + transform: identity, + }); + expect(target).toEqual({ handle: 'se', kind: 'rotate' }); + }); + + it('hits the rotation nub above the top edge (drawn but previously not hit-testable)', () => { + // The nub tip sits `TRANSFORM_ROTATE_NUB_PX` above the top-edge midpoint — + // outside the frame polygon and away from every corner, so before the fix it + // fell through to `null` (misread by the tool as an off-frame press → reset). + const target = transformTargetAt({ + point: nubTip(identity, rect, 1), + rect, + toScreen: identityScreen, + transform: identity, + }); + expect(target).toEqual({ handle: 'n', kind: 'rotate' }); + }); + + it('hits the rotation nub of a rotated + scaled layer at a non-1 zoom', () => { + const transform: LayerTransform = { rotation: 0.4, scaleX: 1.6, scaleY: 1.3, x: 30, y: 20 }; + const target = transformTargetAt({ + point: nubTip(transform, rect, 2), + rect, + toScreen: scaleToScreen(2), + transform, + }); + expect(target).toEqual({ handle: 'n', kind: 'rotate' }); + }); + + it('keeps the interior a move below the nub anchor (nub never steals an interior click)', () => { + // Inside the top edge, clear of the `'n'` scale handle: the polygon wins so a + // near-top interior click stays a move, never a rotate. + const target = transformTargetAt({ point: { x: 50, y: 20 }, rect, toScreen: identityScreen, transform: identity }); + expect(target).toEqual({ kind: 'move' }); + }); + + it('returns null far outside the frame', () => { + const target = transformTargetAt({ + point: { x: 400, y: 400 }, + rect, + toScreen: identityScreen, + transform: identity, + }); + expect(target).toBeNull(); + }); + + it('keeps a constant screen-space grab area under zoom (handle at scaled corner)', () => { + // At zoom 2 the se corner projects to (200,200); a point 3px away still hits. + const target = transformTargetAt({ + point: { x: 202, y: 199 }, + rect, + toScreen: scaleToScreen(2), + transform: identity, + }); + expect(target).toEqual({ handle: 'se', kind: 'scale' }); + }); + + it('hit-tests handles through the layer rotation', () => { + // Rotate 90° about origin: local se (100,100) → doc. Handle tracks the rotated corner. + const rotated: LayerTransform = { rotation: Math.PI / 2, scaleX: 1, scaleY: 1, x: 0, y: 0 }; + const seDoc = applyToPoint(layerTransformMatrix(rotated), localHandlePoint(rect, 'se')); + const target = transformTargetAt({ point: seDoc, rect, toScreen: identityScreen, transform: rotated }); + expect(target).toEqual({ handle: 'se', kind: 'scale' }); + }); +}); + +describe('resizeCursorForHandle', () => { + it('gives axis cursors for an unrotated layer', () => { + expect(resizeCursorForHandle(identity, 'e')).toBe('ew-resize'); + expect(resizeCursorForHandle(identity, 'w')).toBe('ew-resize'); + expect(resizeCursorForHandle(identity, 'n')).toBe('ns-resize'); + expect(resizeCursorForHandle(identity, 's')).toBe('ns-resize'); + }); + + it('gives diagonal cursors for corners', () => { + // se outward normal (1,1) → screen y-down diagonal → nwse. + expect(resizeCursorForHandle(identity, 'se')).toBe('nwse-resize'); + expect(resizeCursorForHandle(identity, 'ne')).toBe('nesw-resize'); + }); + + it('rotates the cursor with the layer (45° turns an edge into a diagonal)', () => { + const rotated: LayerTransform = { rotation: Math.PI / 4, scaleX: 1, scaleY: 1, x: 0, y: 0 }; + // The n edge normal (0,-1) rotated 45° becomes a diagonal → nesw. + expect(resizeCursorForHandle(rotated, 'n')).toBe('nesw-resize'); + // A 90° rotation swaps the axes: the e handle now reads vertical. + const quarter: LayerTransform = { rotation: Math.PI / 2, scaleX: 1, scaleY: 1, x: 0, y: 0 }; + expect(resizeCursorForHandle(quarter, 'e')).toBe('ns-resize'); + }); +}); + +describe('applyScale: about the opposite handle', () => { + it('scales a corner about the opposite corner, tracking the pointer', () => { + const next = applyScale({ + alt: false, + handle: 'se', + pointerDoc: { x: 150, y: 100 }, + shift: false, + rect, + start: identity, + startPointerDoc: { x: 100, y: 100 }, + }); + // nw anchor fixed at (0,0); width doubled in x → scaleX 1.5, scaleY unchanged. + expect(close(next.scaleX, 1.5)).toBe(true); + expect(close(next.scaleY, 1)).toBe(true); + expect(close(next.x, 0)).toBe(true); + expect(close(next.y, 0)).toBe(true); + }); + + it('scales an edge handle on one axis only', () => { + const next = applyScale({ + alt: false, + handle: 'e', + pointerDoc: { x: 150, y: 999 }, + shift: false, + rect, + start: identity, + startPointerDoc: { x: 100, y: 50 }, + }); + expect(close(next.scaleX, 1.5)).toBe(true); + expect(close(next.scaleY, 1)).toBe(true); + }); + + it('keeps the opposite corner fixed in document space', () => { + const start: LayerTransform = { rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 }; + const anchorBefore = applyToPoint(layerTransformMatrix(start), localHandlePoint(rect, 'nw')); + const next = applyScale({ + alt: false, + handle: 'se', + pointerDoc: { x: 200, y: 200 }, + shift: false, + rect, + start, + startPointerDoc: { x: 110, y: 120 }, + }); + const anchorAfter = applyToPoint(layerTransformMatrix(next), localHandlePoint(rect, 'nw')); + expect(close(anchorAfter.x, anchorBefore.x)).toBe(true); + expect(close(anchorAfter.y, anchorBefore.y)).toBe(true); + }); +}); + +describe('applyScale: modifiers', () => { + it('alt scales about the center (center stays fixed)', () => { + const centerBefore = applyToPoint(layerTransformMatrix(identity), { x: 50, y: 50 }); + const next = applyScale({ + alt: true, + handle: 'se', + pointerDoc: { x: 150, y: 150 }, + shift: false, + rect, + start: identity, + startPointerDoc: { x: 100, y: 100 }, + }); + const centerAfter = applyToPoint(layerTransformMatrix(next), { x: 50, y: 50 }); + expect(close(centerAfter.x, centerBefore.x)).toBe(true); + expect(close(centerAfter.y, centerBefore.y)).toBe(true); + }); + + it('shift keeps a uniform aspect on a corner drag', () => { + const next = applyScale({ + alt: false, + handle: 'se', + // Pointer moves mostly in x; uniform factor follows the larger axis. + pointerDoc: { x: 200, y: 110 }, + shift: true, + rect, + start: identity, + startPointerDoc: { x: 100, y: 100 }, + }); + expect(close(next.scaleX, next.scaleY)).toBe(true); + expect(next.scaleX).toBeGreaterThan(1); + }); +}); + +describe('applyScale: flip and clamp', () => { + it('allows a flip through the anchor (negative scale, no NaN)', () => { + const next = applyScale({ + alt: false, + handle: 'e', + // Drag the east edge past the west anchor → negative scaleX. + pointerDoc: { x: -50, y: 50 }, + shift: false, + rect, + start: identity, + startPointerDoc: { x: 100, y: 50 }, + }); + expect(next.scaleX).toBeLessThan(0); + expect(Number.isNaN(next.scaleX)).toBe(false); + }); + + it('clamps a collapsed axis to the minimum magnitude', () => { + const next = applyScale({ + alt: false, + handle: 'e', + // Drag the east edge exactly onto the west anchor → zero width. + pointerDoc: { x: 0, y: 50 }, + shift: false, + rect, + start: identity, + startPointerDoc: { x: 100, y: 50 }, + }); + expect(Math.abs(next.scaleX)).toBeGreaterThanOrEqual(MIN_ABS_SCALE - 1e-9); + expect(Number.isFinite(next.x)).toBe(true); + }); +}); + +describe('applyRotate', () => { + it('rotates about the center by the swept angle', () => { + const next = applyRotate({ + pointerDoc: { x: 50, y: 100 }, // 90° CCW around center from the start ray + shift: false, + rect, + start: identity, + startPointerDoc: { x: 100, y: 50 }, + }); + // start ray points +x; end ray points +y (down) → +90° in canvas space. + expect(close(next.rotation, Math.PI / 2)).toBe(true); + // Center stays fixed. + const center = applyToPoint(layerTransformMatrix(next), { x: 50, y: 50 }); + expect(close(center.x, 50)).toBe(true); + expect(close(center.y, 50)).toBe(true); + }); + + it('snaps to 15° increments under shift', () => { + const next = applyRotate({ + pointerDoc: { x: 100, y: 62 }, // a small angle just above 0 + shift: true, + rect, + start: identity, + startPointerDoc: { x: 100, y: 50 }, + }); + const remainder = Math.abs(next.rotation % ROTATE_SNAP_RAD); + expect(remainder < 1e-9 || Math.abs(remainder - ROTATE_SNAP_RAD) < 1e-9).toBe(true); + }); +}); + +describe('snapRotation', () => { + it('rounds to the nearest 15°', () => { + expect(close(snapRotation(ROTATE_SNAP_RAD * 0.4), 0)).toBe(true); + expect(close(snapRotation(ROTATE_SNAP_RAD * 0.6), ROTATE_SNAP_RAD)).toBe(true); + }); +}); + +describe('applyMove', () => { + it('translates by the document delta', () => { + expect(applyMove(identity, { x: 5, y: -3 }, false)).toMatchObject({ x: 5, y: -3, rotation: 0, scaleX: 1 }); + }); + it('constrains to the dominant axis under shift', () => { + expect(applyMove(identity, { x: 8, y: 2 }, true)).toMatchObject({ x: 8, y: 0 }); + expect(applyMove(identity, { x: 1, y: -9 }, true)).toMatchObject({ x: 0, y: -9 }); + }); +}); + +describe('bakeMatrix', () => { + it('equals the layer matrix so bake-then-draw ≡ transform-then-draw', () => { + const transform: LayerTransform = { rotation: Math.PI / 6, scaleX: 1.5, scaleY: 0.8, x: 12, y: 34 }; + const bake = bakeMatrix(transform); + const direct = layerTransformMatrix(transform); + // For any local point, drawing the baked surface at identity reproduces the + // transformed point exactly. + for (const q of [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 40, y: 70 }, + ]) { + const viaBake = applyToPoint(bake, q); // baked surface then identity draw + const viaTransform = applyToPoint(direct, q); + expect(close(viaBake.x, viaTransform.x)).toBe(true); + expect(close(viaBake.y, viaTransform.y)).toBe(true); + } + }); +}); + +describe('transformOverlayGeometry', () => { + it('returns rotated corners, eight handles, center, and the top anchor', () => { + const geo = transformOverlayGeometry(identity, rect); + expect(geo.corners).toHaveLength(4); + expect(geo.handles).toHaveLength(8); + expect(geo.center).toEqual({ x: 50, y: 50 }); + expect(geo.rotationAnchor).toEqual({ x: 50, y: 0 }); + }); +}); + +describe('IDENTITY_TRANSFORM', () => { + it('is a true identity', () => { + expect(IDENTITY_TRANSFORM).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/transform/transformMath.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/transform/transformMath.ts new file mode 100644 index 00000000000..86a18d21aab --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/transform/transformMath.ts @@ -0,0 +1,429 @@ +/** + * Pure transform math for the transform tool: handle geometry, screen-space + * hit-testing over a rotated/scaled layer, per-handle rotation-aware cursors, and + * the scale-about-anchor / rotate / move transform derivations. + * + * A layer's raster cache holds unscaled pixels in the layer's LOCAL space; the + * compositor draws it through `fromTRS(transform)` (translate·rotate·scale). The + * transform tool edits that {@link LayerTransform}. All handle positions are + * computed in LOCAL space, mapped to document space via the layer matrix, then + * projected to SCREEN space by the caller-supplied `toScreen` so grab areas stay + * a constant pixel size regardless of zoom (mirrors `bboxHitTest.ts`, generalized + * to a rotated frame). Every derivation returns a fresh transform from a captured + * `start` transform plus a pointer delta — negative scale (a flip past the anchor) + * is allowed and kept correct; `|scale|` is clamped to {@link MIN_ABS_SCALE} so a + * collapsed axis never produces a singular/NaN matrix. + * + * Zero React, zero DOM, zero import-time side effects. + */ + +import type { Mat2d, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasLayerBaseContract } from '@workbench/types'; + +import { applyToPoint, fromTRS } from '@workbench/canvas-engine/math/mat2d'; + +/** The editable layer transform (translate + per-axis scale + rotation in radians). */ +export type LayerTransform = CanvasLayerBaseContract['transform']; + +/** + * The layer's content rectangle in its LOCAL (untransformed) space — the extent + * being transformed. Content-sized layers can sit off-origin (a paint layer's + * persisted offset, negative for strokes up/left of the origin), so the frame, + * handles, and scale/rotate anchors are all derived from this rect, never from + * an assumed `[0,w]×[0,h]`. + */ +export interface TransformRect { + x: number; + y: number; + width: number; + height: number; +} + +/** The eight scale handles: four corners + four edge midpoints. */ +export type TransformHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; + +/** + * What a pointer landed on over the transform frame: a scale handle, a corner + * rotate zone (just outside a corner), or the interior (`'move'`). + */ +export type TransformTarget = + | { kind: 'scale'; handle: TransformHandle } + | { kind: 'rotate'; handle: TransformHandle } + | { kind: 'move' }; + +/** Handles in hit-test priority order: corners first (they win over adjacent edges). */ +export const TRANSFORM_HANDLES: readonly TransformHandle[] = ['nw', 'ne', 'se', 'sw', 'n', 'e', 's', 'w']; + +/** The four corner handles (also the rotate-zone anchors). */ +export const CORNER_HANDLES: readonly TransformHandle[] = ['nw', 'ne', 'se', 'sw']; + +/** Side length (screen px) of a scale handle's square grab area. */ +export const TRANSFORM_HANDLE_HIT_PX = 14; + +/** Radius (screen px) of a corner's rotate zone, measured from the corner outward. */ +export const TRANSFORM_ROTATE_ZONE_PX = 22; + +/** + * Screen-px length of the rotation-indicator nub the overlay draws past the top + * edge midpoint (the `'n'` handle), outward away from the center. Shared with the + * overlay renderer so the drawn nub and its hit region stay in lock-step. + */ +export const TRANSFORM_ROTATE_NUB_PX = 18; + +/** Screen-px grab radius around the rotation nub/knob (the capsule half-width). */ +export const TRANSFORM_ROTATE_NUB_HIT_PX = 11; + +/** Rotation snap increment under shift: 15°. */ +export const ROTATE_SNAP_RAD = Math.PI / 12; + +/** Smallest permitted |scaleX|/|scaleY| — keeps the layer matrix invertible. */ +export const MIN_ABS_SCALE = 0.01; + +/** The corner/edge opposite a handle (the fixed anchor for a non-alt scale). */ +const OPPOSITE: Record = { + e: 'w', + n: 's', + ne: 'sw', + nw: 'se', + s: 'n', + se: 'nw', + sw: 'ne', + w: 'e', +}; + +const hasWest = (h: TransformHandle): boolean => h === 'nw' || h === 'w' || h === 'sw'; +const hasEast = (h: TransformHandle): boolean => h === 'ne' || h === 'e' || h === 'se'; +const hasNorth = (h: TransformHandle): boolean => h === 'nw' || h === 'n' || h === 'ne'; +const hasSouth = (h: TransformHandle): boolean => h === 'sw' || h === 's' || h === 'se'; + +/** True for the four corner handles (two-letter ids). */ +export const isCornerHandle = (h: TransformHandle): boolean => h.length === 2; + +/** A handle's position on the layer's local content rect (off-origin aware). */ +export const localHandlePoint = (rect: TransformRect, handle: TransformHandle): Vec2 => ({ + x: rect.x + (hasWest(handle) ? 0 : hasEast(handle) ? rect.width : rect.width / 2), + y: rect.y + (hasNorth(handle) ? 0 : hasSouth(handle) ? rect.height : rect.height / 2), +}); + +/** The local content rect's center (the rotation pivot / alt-scale anchor). */ +const localCenter = (rect: TransformRect): Vec2 => ({ + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, +}); + +/** The layer's local→document affine matrix from its transform. */ +export const layerTransformMatrix = (t: LayerTransform): Mat2d => + fromTRS({ x: t.x, y: t.y }, t.rotation, t.scaleX, t.scaleY); + +/** The rotation matrix for `rad` (linear part only). */ +const rotationMat = (rad: number): { a: number; b: number; c: number; d: number } => { + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return { a: cos, b: sin, c: -sin, d: cos }; +}; + +/** Applies a rotation matrix's linear part to a vector. */ +const applyLinear = (m: { a: number; b: number; c: number; d: number }, v: Vec2): Vec2 => ({ + x: m.a * v.x + m.c * v.y, + y: m.b * v.x + m.d * v.y, +}); + +/** A handle's outward normal in LOCAL space (y-down, matching document/screen). */ +const localOutward = (handle: TransformHandle): Vec2 => ({ + x: hasWest(handle) ? -1 : hasEast(handle) ? 1 : 0, + y: hasNorth(handle) ? -1 : hasSouth(handle) ? 1 : 0, +}); + +/** + * Geometry the overlay needs to draw the transform frame, in DOCUMENT space + * (the overlay projects each point through the view). `handles` is ordered to + * match {@link TRANSFORM_HANDLES}. + */ +export interface TransformOverlayGeometry { + /** The four rotated-rect corners (nw, ne, se, sw) for the bounds polygon. */ + corners: Vec2[]; + /** The eight scale-handle positions (order = {@link TRANSFORM_HANDLES}). */ + handles: Vec2[]; + /** The layer center (rotation pivot). */ + center: Vec2; + /** The top edge midpoint (root of the rotation indicator nub). */ + rotationAnchor: Vec2; +} + +/** Document-space geometry for the transform overlay of a layer at `transform`. */ +export const transformOverlayGeometry = (transform: LayerTransform, rect: TransformRect): TransformOverlayGeometry => { + const m = layerTransformMatrix(transform); + return { + center: applyToPoint(m, localCenter(rect)), + corners: (['nw', 'ne', 'se', 'sw'] as const).map((h) => applyToPoint(m, localHandlePoint(rect, h))), + handles: TRANSFORM_HANDLES.map((h) => applyToPoint(m, localHandlePoint(rect, h))), + rotationAnchor: applyToPoint(m, localHandlePoint(rect, 'n')), + }; +}; + +/** True when `pt` is inside the convex polygon `poly` (consistent-winding cross test). */ +const pointInConvexPolygon = (poly: readonly Vec2[], pt: Vec2): boolean => { + let pos = false; + let neg = false; + for (let i = 0; i < poly.length; i++) { + const a = poly[i]!; + const b = poly[(i + 1) % poly.length]!; + const cross = (b.x - a.x) * (pt.y - a.y) - (b.y - a.y) * (pt.x - a.x); + if (cross > 1e-9) { + pos = true; + } else if (cross < -1e-9) { + neg = true; + } + if (pos && neg) { + return false; + } + } + return true; +}; + +/** Distance (screen px) from `p` to the segment `a`→`b`. */ +const distanceToSegment = (p: Vec2, a: Vec2, b: Vec2): number => { + const abx = b.x - a.x; + const aby = b.y - a.y; + const len2 = abx * abx + aby * aby; + const t = len2 > 0 ? Math.max(0, Math.min(1, ((p.x - a.x) * abx + (p.y - a.y) * aby) / len2)) : 0; + return Math.hypot(p.x - (a.x + t * abx), p.y - (a.y + t * aby)); +}; + +/** Parameters for {@link transformTargetAt}. */ +export interface TransformHitTestParams { + transform: LayerTransform; + rect: TransformRect; + /** Projects a document-space point to screen space (CSS px). */ + toScreen: (docPoint: Vec2) => Vec2; + /** The screen-space pointer position (CSS px). */ + point: Vec2; + /** Scale-handle grab-area side length (screen px). */ + handleHitPx?: number; + /** Corner rotate-zone radius (screen px). */ + rotateZonePx?: number; + /** Rotation-nub grab radius (screen px). */ + rotateNubHitPx?: number; +} + +/** + * What a screen-space `point` targets on the transform frame: a scale handle + * (grab area first, corners before edges), a corner rotate zone (outside the + * frame, near a corner), the interior (`'move'`), or `null`. + */ +export const transformTargetAt = (params: TransformHitTestParams): TransformTarget | null => { + const { point, rect, toScreen, transform } = params; + const half = (params.handleHitPx ?? TRANSFORM_HANDLE_HIT_PX) / 2; + const m = layerTransformMatrix(transform); + const screenOf = (handle: TransformHandle): Vec2 => toScreen(applyToPoint(m, localHandlePoint(rect, handle))); + + for (const handle of TRANSFORM_HANDLES) { + const s = screenOf(handle); + if (Math.abs(point.x - s.x) <= half && Math.abs(point.y - s.y) <= half) { + return { handle, kind: 'scale' }; + } + } + + const screenCorners = CORNER_HANDLES.map(screenOf); + if (pointInConvexPolygon(screenCorners, point)) { + return { kind: 'move' }; + } + + // Rotation nub: a capsule from the top-edge (`'n'`) midpoint outward (away + // from center) by `TRANSFORM_ROTATE_NUB_PX`, matching the drawn nub. Tested + // after the interior polygon (so an interior click stays a move) and before + // the corner rotate zones (disjoint regions — a top-middle nub vs corners). + // Without this, a click on the visible nub falls through to `null`, which the + // tool misreads as an off-frame press — re-opening the session (resetting its + // live transform) instead of starting a rotation. + const anchorScreen = screenOf('n'); + const centerScreen = toScreen(applyToPoint(m, { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 })); + const outX = anchorScreen.x - centerScreen.x; + const outY = anchorScreen.y - centerScreen.y; + const outLen = Math.hypot(outX, outY) || 1; + const nubTip: Vec2 = { + x: anchorScreen.x + (outX / outLen) * TRANSFORM_ROTATE_NUB_PX, + y: anchorScreen.y + (outY / outLen) * TRANSFORM_ROTATE_NUB_PX, + }; + if (distanceToSegment(point, anchorScreen, nubTip) <= (params.rotateNubHitPx ?? TRANSFORM_ROTATE_NUB_HIT_PX)) { + return { handle: 'n', kind: 'rotate' }; + } + + const zone = params.rotateZonePx ?? TRANSFORM_ROTATE_ZONE_PX; + for (let i = 0; i < CORNER_HANDLES.length; i++) { + const s = screenCorners[i]!; + if (Math.hypot(point.x - s.x, point.y - s.y) <= zone) { + return { handle: CORNER_HANDLES[i]!, kind: 'rotate' }; + } + } + return null; +}; + +/** The four resize cursors, indexed by the quantized 45° bucket of the outward normal. */ +const RESIZE_CURSORS = ['ew-resize', 'nesw-resize', 'ns-resize', 'nwse-resize'] as const; + +/** + * The resize cursor for a scale handle, accounting for the layer's rotation: + * the handle's outward normal is rotated/scaled by the layer matrix, then the + * resulting screen-space angle is quantized to one of the four resize + * orientations. (The view adds only a uniform scale — no rotation/flip — so the + * document-space angle equals the screen-space angle.) + */ +export const resizeCursorForHandle = (transform: LayerTransform, handle: TransformHandle): string => { + const m = layerTransformMatrix(transform); + const dir = applyLinear(m, localOutward(handle)); + // Screen space is y-down; negate y to get a conventional (y-up) math angle. + let deg = (Math.atan2(-dir.y, dir.x) * 180) / Math.PI; + deg = ((deg % 360) + 360) % 360; + deg %= 180; // opposite directions share a resize cursor + const bucket = Math.round(deg / 45) % 4; + return RESIZE_CURSORS[bucket]!; +}; + +/** Clamps |s| to at least {@link MIN_ABS_SCALE}, preserving sign (0 → +MIN). */ +const clampScale = (s: number): number => { + if (!Number.isFinite(s) || s === 0) { + return MIN_ABS_SCALE; + } + const sign = s < 0 ? -1 : 1; + return Math.abs(s) < MIN_ABS_SCALE ? sign * MIN_ABS_SCALE : s; +}; + +/** Applies shift axis-constraint to a document-space delta (dominant axis wins). */ +export const constrainMoveDelta = (delta: Vec2, shift: boolean): Vec2 => { + if (!shift) { + return delta; + } + return Math.abs(delta.x) >= Math.abs(delta.y) ? { x: delta.x, y: 0 } : { x: 0, y: delta.y }; +}; + +/** Translates `start` by a document-space pointer delta (shift = axis constrain). */ +export const applyMove = (start: LayerTransform, deltaDoc: Vec2, shift: boolean): LayerTransform => { + const d = constrainMoveDelta(deltaDoc, shift); + return { ...start, x: start.x + d.x, y: start.y + d.y }; +}; + +/** Parameters for {@link applyScale}. */ +export interface ScaleParams { + start: LayerTransform; + rect: TransformRect; + handle: TransformHandle; + /** Pointer position (document space) at gesture start. */ + startPointerDoc: Vec2; + /** Current pointer position (document space). */ + pointerDoc: Vec2; + /** Preserve aspect ratio (corner handles only). */ + shift: boolean; + /** Scale about the layer center instead of the opposite handle. */ + alt: boolean; +} + +/** + * Scales `start` by dragging `handle`, anchored at the opposite handle (or the + * center under `alt`). The grabbed handle tracks the pointer; edge handles scale + * one axis, corners scale both (`shift` forces a uniform, aspect-preserving + * factor). Dragging past the anchor flips the axis (negative scale), which is + * kept correct; each axis is clamped to {@link MIN_ABS_SCALE}. + */ +export const applyScale = (p: ScaleParams): LayerTransform => { + const { alt, handle, rect, shift, start } = p; + const m = layerTransformMatrix(start); + const handleLocal = localHandlePoint(rect, handle); + const anchorLocal = alt ? localCenter(rect) : localHandlePoint(rect, OPPOSITE[handle]); + const anchorDoc = applyToPoint(m, anchorLocal); + const handleDoc = applyToPoint(m, handleLocal); + const targetDoc: Vec2 = { + x: handleDoc.x + (p.pointerDoc.x - p.startPointerDoc.x), + y: handleDoc.y + (p.pointerDoc.y - p.startPointerDoc.y), + }; + + // Un-rotate the anchor→target vector into the layer's scaled-local frame. + const cos = Math.cos(start.rotation); + const sin = Math.sin(start.rotation); + const dx = targetDoc.x - anchorDoc.x; + const dy = targetDoc.y - anchorDoc.y; + const vx = cos * dx + sin * dy; + const vy = -sin * dx + cos * dy; + + const dlx = handleLocal.x - anchorLocal.x; + const dly = handleLocal.y - anchorLocal.y; + let sx = dlx !== 0 ? vx / dlx : start.scaleX; + let sy = dly !== 0 ? vy / dly : start.scaleY; + + if (shift && isCornerHandle(handle)) { + const fx = start.scaleX !== 0 ? sx / start.scaleX : 1; + const fy = start.scaleY !== 0 ? sy / start.scaleY : 1; + const f = Math.abs(fx) >= Math.abs(fy) ? fx : fy; + sx = start.scaleX * f; + sy = start.scaleY * f; + } + + sx = clampScale(sx); + sy = clampScale(sy); + + // Keep the anchor fixed: T = anchorDoc − R·(sx·anchorLocal.x, sy·anchorLocal.y). + const rs = applyLinear(rotationMat(start.rotation), { x: sx * anchorLocal.x, y: sy * anchorLocal.y }); + return { + rotation: start.rotation, + scaleX: sx, + scaleY: sy, + x: anchorDoc.x - rs.x, + y: anchorDoc.y - rs.y, + }; +}; + +/** Snaps a radian angle to the nearest {@link ROTATE_SNAP_RAD} increment. */ +export const snapRotation = (rad: number): number => Math.round(rad / ROTATE_SNAP_RAD) * ROTATE_SNAP_RAD; + +/** Parameters for {@link applyRotate}. */ +export interface RotateParams { + start: LayerTransform; + rect: TransformRect; + startPointerDoc: Vec2; + pointerDoc: Vec2; + /** Snap the resulting absolute rotation to 15° increments. */ + shift: boolean; +} + +/** + * Rotates `start` about the layer center by the angle the pointer sweeps around + * that center since gesture start. `shift` snaps the resulting absolute rotation + * to 15° increments. Scale is unchanged. + */ +export const applyRotate = (p: RotateParams): LayerTransform => { + const { rect, start } = p; + const m = layerTransformMatrix(start); + const centerLocal = localCenter(rect); + const center = applyToPoint(m, centerLocal); + const a0 = Math.atan2(p.startPointerDoc.y - center.y, p.startPointerDoc.x - center.x); + const a1 = Math.atan2(p.pointerDoc.y - center.y, p.pointerDoc.x - center.x); + let rotation = start.rotation + (a1 - a0); + if (p.shift) { + rotation = snapRotation(rotation); + } + // Keep the center fixed: T = center − R(rotation)·(sx·cx, sy·cy). + const rs = applyLinear(rotationMat(rotation), { + x: start.scaleX * centerLocal.x, + y: start.scaleY * centerLocal.y, + }); + return { + rotation, + scaleX: start.scaleX, + scaleY: start.scaleY, + x: center.x - rs.x, + y: center.y - rs.y, + }; +}; + +/** + * The matrix that renders a layer's local (cache) pixels into a NEW document-space + * surface so that the result, drawn at identity, reproduces the layer as if drawn + * through `transform` — i.e. `fromTRS(transform)`. Used by the paint-layer bake: + * old pixels are drawn through this matrix, the layer transform is then reset to + * identity, and the composite is unchanged. Kept as a named helper so the bake + * site reads intentionally and composes with `math/mat2d`. + */ +export const bakeMatrix = (transform: LayerTransform): Mat2d => layerTransformMatrix(transform); + +/** The identity transform (a reset layer transform after a bake). */ +export const IDENTITY_TRANSFORM: LayerTransform = { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/types.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/types.ts new file mode 100644 index 00000000000..a7678c5342c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/types.ts @@ -0,0 +1,118 @@ +/** + * Core types for the canvas engine. + * + * This module is the shared vocabulary for the imperative canvas engine + * (`src/workbench/canvas-engine/`). It has zero React imports and zero + * side effects at import time — it is pure type/interface declarations (the + * single type-only import below is erased at compile time). + */ + +import type { RasterSurface } from './render/raster'; + +/** A 2D point or vector. */ +export interface Vec2 { + x: number; + y: number; +} + +/** An axis-aligned rectangle. */ +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +/** + * A raster surface placed in some coordinate space: the surface holds pixels for + * `rect` (its bounds in that space), so surface pixel `(sx, sy)` maps to + * `(rect.x + sx, rect.y + sy)`. Used for content-sized layer caches (layer-local + * space) and the bounded selection mask (document space). + */ +export interface PlacedSurface { + surface: RasterSurface; + rect: Rect; +} + +/** + * A 2D affine transform matrix, using the canvas transform convention: + * + * ``` + * x' = a*x + c*y + e + * y' = b*x + d*y + f + * ``` + * + * This matches `CanvasRenderingContext2D.setTransform(a, b, c, d, e, f)` and + * the DOMMatrix 2D constructor argument order. + */ +export interface Mat2d { + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; +} + +/** Identifiers for the tools the engine's interaction layer can dispatch to. */ +export type ToolId = + | 'brush' + | 'eraser' + | 'shape' + | 'gradient' + | 'lasso' + | 'move' + | 'transform' + | 'bbox' + | 'view' + | 'colorPicker' + | 'text' + | 'sam'; + +/** + * A pixel-selection boolean operation applied when a new lasso path commits + * against the existing selection mask (see `selection/selectionState.ts`): + * `replace` swaps it, `add` unions, `subtract` cuts out, `intersect` keeps the + * overlap. + */ +export type SelectionOp = 'replace' | 'add' | 'subtract' | 'intersect'; + +/** Modifier key state accompanying a pointer sample. */ +export interface PointerModifiers { + shift: boolean; + alt: boolean; + ctrl: boolean; + meta: boolean; +} + +/** A normalized pointer sample fed into the engine's interaction layer. */ +export interface PointerInput { + /** Pointer position in document space (unaffected by pan/zoom). */ + documentPoint: Vec2; + /** Pointer position in screen/viewport space (CSS pixels). */ + screenPoint: Vec2; + /** Pressure in [0, 1]. 0.5 is used for devices that don't report pressure. */ + pressure: number; + /** Bitmask of currently-pressed buttons, matching `PointerEvent.buttons`. */ + buttons: number; + modifiers: PointerModifiers; + pointerType: 'mouse' | 'pen' | 'touch'; + /** High-resolution timestamp (ms), matching `PointerEvent.timeStamp`. */ + timeStamp: number; +} + +/** + * Describes what needs to be re-rendered on the next frame. Consumers + * (scheduler/compositor, added in later tasks) union flags together rather + * than always doing a full repaint. + */ +export interface RenderFlags { + /** The viewport transform (pan/zoom) changed. */ + view: boolean; + /** Ids of layers whose pixel content or transform changed. */ + layers: Set; + /** Interaction overlays (selection, cursors, guides) changed. */ + overlay: boolean; + /** Force a full repaint, ignoring the other flags. */ + all: boolean; +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/viewport.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/viewport.test.ts new file mode 100644 index 00000000000..6558f61a7ff --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/viewport.test.ts @@ -0,0 +1,114 @@ +import type { Vec2 } from '@workbench/canvas-engine/types'; + +import { applyToPoint } from '@workbench/canvas-engine/math/mat2d'; +import { ZOOM_SNAP_CANDIDATES } from '@workbench/canvas-engine/math/snapping'; +import { describe, expect, it, vi } from 'vitest'; + +import { createViewport } from './viewport'; + +const closeTo = (a: Vec2, b: Vec2, eps = 1e-6): void => { + expect(a.x).toBeCloseTo(b.x, 6); + expect(a.y).toBeCloseTo(b.y, 6); + void eps; +}; + +describe('createViewport', () => { + it('round-trips screen↔document with pan and zoom', () => { + const vp = createViewport({ pan: { x: 30, y: -12 }, zoom: 1.5 }); + const doc: Vec2 = { x: 42, y: 17 }; + const screen = vp.documentToScreen(doc); + // screen = zoom*doc + pan + closeTo(screen, { x: 1.5 * 42 + 30, y: 1.5 * 17 - 12 }); + closeTo(vp.screenToDocument(screen), doc); + }); + + it('viewMatrix folds dpr into scale and translation (device = dpr * screen)', () => { + const vp = createViewport({ pan: { x: 10, y: 5 }, zoom: 2 }); + const dpr = 2; + const m = vp.viewMatrix(dpr); + const doc: Vec2 = { x: 7, y: 9 }; + const device = applyToPoint(m, doc); + const screen = vp.documentToScreen(doc); + closeTo(device, { x: screen.x * dpr, y: screen.y * dpr }); + }); + + it('zoomAtPoint keeps the anchored document point fixed on screen', () => { + const vp = createViewport({ pan: { x: 0, y: 0 }, zoom: 1 }); + const anchor: Vec2 = { x: 200, y: 120 }; + const docUnderAnchorBefore = vp.screenToDocument(anchor); + vp.zoomAtPoint(3, anchor); + expect(vp.getZoom()).toBe(3); + // The document point under the anchor must project back to the same screen anchor. + closeTo(vp.documentToScreen(docUnderAnchorBefore), anchor); + }); + + it('wheelZoom snaps to a candidate when the exponential step lands near one', () => { + const vp = createViewport({ pan: { x: 0, y: 0 }, zoom: 1 }); + const anchor: Vec2 = { x: 0, y: 0 }; + // A tiny negative deltaY nudges zoom just above 1; it should snap back to 1. + vp.wheelZoom(-1, anchor); + expect(vp.getZoom()).toBe(1); + expect(ZOOM_SNAP_CANDIDATES).toContain(vp.getZoom()); + }); + + it('wheelZoom zooms out on positive deltaY and in on negative deltaY', () => { + const vpOut = createViewport({ zoom: 1 }); + vpOut.wheelZoom(400, { x: 0, y: 0 }); + expect(vpOut.getZoom()).toBeLessThan(1); + + const vpIn = createViewport({ zoom: 1 }); + vpIn.wheelZoom(-400, { x: 0, y: 0 }); + expect(vpIn.getZoom()).toBeGreaterThan(1); + }); + + it('fitToView centers the document and applies padding-limited zoom', () => { + const vp = createViewport(); + const documentRect = { height: 100, width: 200, x: 0, y: 0 }; + const viewportSize = { height: 400, width: 400 }; + const padding = 50; + vp.fitToView(documentRect, viewportSize, padding); + + // avail = 400 - 2*50 = 300; zoom = min(300/200, 300/100) = 1.5 + expect(vp.getZoom()).toBeCloseTo(1.5, 6); + // Document center maps to viewport center. + const docCenter: Vec2 = { x: 100, y: 50 }; + closeTo(vp.documentToScreen(docCenter), { x: 200, y: 200 }); + }); + + it('fitToView is a no-op for a degenerate viewport', () => { + const vp = createViewport({ zoom: 2 }); + vp.fitToView({ height: 100, width: 100, x: 0, y: 0 }, { height: 0, width: 0 }); + expect(vp.getZoom()).toBe(2); + }); + + it('panBy translates in screen space', () => { + const vp = createViewport({ pan: { x: 5, y: 5 }, zoom: 2 }); + vp.panBy({ x: 10, y: -4 }); + expect(vp.getState().pan).toEqual({ x: 15, y: 1 }); + }); + + it('setViewportSize clamps dpr to the max and notifies on change', () => { + const vp = createViewport(); + const listener = vi.fn(); + vp.subscribe(listener); + vp.setViewportSize(800, 600, 3); + expect(vp.getDpr()).toBe(2); + expect(vp.getViewportSize()).toEqual({ height: 600, width: 800 }); + expect(listener).toHaveBeenCalledTimes(1); + // Identical call does not re-notify. + vp.setViewportSize(800, 600, 3); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('notifies subscribers on zoom/pan and stops after unsubscribe', () => { + const vp = createViewport(); + const listener = vi.fn(); + const unsubscribe = vp.subscribe(listener); + vp.panBy({ x: 1, y: 0 }); + vp.zoomAtPoint(2, { x: 0, y: 0 }); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + vp.panBy({ x: 1, y: 0 }); + expect(listener).toHaveBeenCalledTimes(2); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/viewport.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/viewport.ts new file mode 100644 index 00000000000..1715e8b9aa6 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/viewport.ts @@ -0,0 +1,185 @@ +/** + * The pan/zoom viewport: the single source of truth for how document space + * maps to the on-screen canvas. It owns `{ pan, zoom }`, the CSS viewport + * size, and the device-pixel ratio, and derives the document→screen + * transform used by both the renderer (device pixels) and pointer routing + * (CSS pixels). + * + * ## Coordinate convention + * + * - **Document space**: the canvas document's own pixel grid, unaffected by + * pan/zoom (a layer at document `(0,0)` is the top-left of the document). + * - **Screen/CSS space**: CSS pixels relative to the canvas element's + * top-left. This is what `PointerEvent.clientX/Y` (minus the element rect) + * gives you. `pan` is stored in CSS pixels. + * - **Device space**: the canvas backing store, `CSS × dpr`. The renderer + * draws here. + * + * The mappings are: + * ``` + * screen = zoom * doc + pan (CSS pixels) + * device = dpr * screen = (zoom*dpr) * doc + dpr*pan + * ``` + * so `viewMatrix(dpr)` is `scale(zoom*dpr)` followed by a `dpr*pan` + * translation — i.e. `a = d = zoom*dpr`, `e = dpr*pan.x`, `f = dpr*pan.y`. + * + * Zero React, zero import-time side effects; DOM-free (operates on plain + * numbers), so it runs unchanged in node tests. + */ + +import type { Mat2d, Rect, Vec2 } from '@workbench/canvas-engine/types'; + +import { applyToPoint, invert } from '@workbench/canvas-engine/math/mat2d'; +import { clampZoom, snapZoom } from '@workbench/canvas-engine/math/snapping'; + +/** Maximum device-pixel ratio the engine honors; higher DPRs are clamped to bound backing-store size. */ +export const MAX_DPR = 2; + +/** Wheel exponential zoom sensitivity: `zoom *= exp(-deltaY * step)`. */ +export const WHEEL_ZOOM_STEP = 0.0015; + +/** The mutable view state. */ +export interface ViewState { + pan: Vec2; + zoom: number; +} + +/** A `{ width, height }` size in CSS pixels. */ +export interface Size { + width: number; + height: number; +} + +/** The imperative viewport handle. */ +export interface Viewport { + /** Current `{ pan, zoom }` (a fresh copy — never mutate the return value). */ + getState(): ViewState; + /** Current zoom factor. */ + getZoom(): number; + /** Current CSS viewport size. */ + getViewportSize(): Size; + /** Current (clamped) device-pixel ratio. */ + getDpr(): number; + /** The document→device-pixel transform for the given `dpr`. */ + viewMatrix(dpr: number): Mat2d; + /** Maps a CSS-pixel screen point to document space. */ + screenToDocument(p: Vec2): Vec2; + /** Maps a document point to a CSS-pixel screen point. */ + documentToScreen(p: Vec2): Vec2; + /** Sets zoom while keeping the document point under `screenAnchor` fixed. */ + zoomAtPoint(newZoom: number, screenAnchor: Vec2): void; + /** Exponential wheel zoom about `screenAnchor`, snapping near common zoom levels. */ + wheelZoom(deltaY: number, screenAnchor: Vec2): void; + /** Pans by a CSS-pixel screen delta. */ + panBy(screenDelta: Vec2): void; + /** Centers and zooms to fit `documentRect` within `viewportSize` (minus `padding`). */ + fitToView(documentRect: Rect, viewportSize: Size, padding?: number): void; + /** Records the CSS viewport size and device-pixel ratio (dpr clamped to {@link MAX_DPR}). */ + setViewportSize(width: number, height: number, dpr: number): void; + /** Subscribes to any view change. Returns an unsubscribe function. */ + subscribe(listener: () => void): () => void; +} + +/** Default fit-to-view padding in CSS pixels. */ +const DEFAULT_FIT_PADDING = 48; + +/** Creates a viewport, optionally seeded with an initial view state / size. */ +export const createViewport = (initial?: Partial): Viewport => { + let pan: Vec2 = { x: initial?.pan?.x ?? 0, y: initial?.pan?.y ?? 0 }; + let zoom = clampZoom(initial?.zoom ?? 1); + let size: Size = { height: 0, width: 0 }; + let dpr = 1; + + const listeners = new Set<() => void>(); + + const emit = (): void => { + for (const listener of listeners) { + listener(); + } + }; + + const cssMatrix = (): Mat2d => ({ a: zoom, b: 0, c: 0, d: zoom, e: pan.x, f: pan.y }); + + const documentToScreen = (p: Vec2): Vec2 => ({ x: zoom * p.x + pan.x, y: zoom * p.y + pan.y }); + + const screenToDocument = (p: Vec2): Vec2 => { + const inverse = invert(cssMatrix()); + if (!inverse) { + return { x: 0, y: 0 }; + } + return applyToPoint(inverse, p); + }; + + const zoomAtPoint = (newZoom: number, screenAnchor: Vec2): void => { + const clamped = clampZoom(newZoom); + // Keep the document point currently under the anchor fixed on screen. + const docAnchor = screenToDocument(screenAnchor); + zoom = clamped; + pan = { x: screenAnchor.x - clamped * docAnchor.x, y: screenAnchor.y - clamped * docAnchor.y }; + emit(); + }; + + const wheelZoom = (deltaY: number, screenAnchor: Vec2): void => { + const target = clampZoom(zoom * Math.exp(-deltaY * WHEEL_ZOOM_STEP)); + zoomAtPoint(snapZoom(target), screenAnchor); + }; + + const panBy = (screenDelta: Vec2): void => { + pan = { x: pan.x + screenDelta.x, y: pan.y + screenDelta.y }; + emit(); + }; + + const fitToView = (documentRect: Rect, viewportSize: Size, padding: number = DEFAULT_FIT_PADDING): void => { + const availWidth = viewportSize.width - padding * 2; + const availHeight = viewportSize.height - padding * 2; + if (documentRect.width <= 0 || documentRect.height <= 0 || availWidth <= 0 || availHeight <= 0) { + return; + } + const fitZoom = clampZoom(Math.min(availWidth / documentRect.width, availHeight / documentRect.height)); + const docCenter: Vec2 = { + x: documentRect.x + documentRect.width / 2, + y: documentRect.y + documentRect.height / 2, + }; + zoom = fitZoom; + pan = { x: viewportSize.width / 2 - fitZoom * docCenter.x, y: viewportSize.height / 2 - fitZoom * docCenter.y }; + emit(); + }; + + const setViewportSize = (width: number, height: number, nextDpr: number): void => { + const clampedDpr = Math.max(1, Math.min(MAX_DPR, nextDpr)); + if (size.width === width && size.height === height && dpr === clampedDpr) { + return; + } + size = { height, width }; + dpr = clampedDpr; + emit(); + }; + + return { + documentToScreen, + fitToView, + getDpr: () => dpr, + getState: () => ({ pan: { x: pan.x, y: pan.y }, zoom }), + getViewportSize: () => ({ height: size.height, width: size.width }), + getZoom: () => zoom, + panBy, + screenToDocument, + setViewportSize, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + viewMatrix: (matrixDpr) => ({ + a: zoom * matrixDpr, + b: 0, + c: 0, + d: zoom * matrixDpr, + e: pan.x * matrixDpr, + f: pan.y * matrixDpr, + }), + wheelZoom, + zoomAtPoint, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/FilterOperationCoordinator.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/FilterOperationCoordinator.test.ts new file mode 100644 index 00000000000..9dd828b7956 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/FilterOperationCoordinator.test.ts @@ -0,0 +1,97 @@ +import type { FilterOperationSession } from '@workbench/canvas-operations/contracts'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createFilterOperationCoordinator } from './FilterOperationCoordinator'; +import { createCanvasOperationStores } from './operationStores'; + +const layer = { + filter: null, + id: 'layer-1', + isEnabled: true, + isLocked: false, + name: 'Layer 1', + type: 'raster', +} as const; + +const createSession = (): FilterOperationSession => ({ + blockCommit: vi.fn(), + cancel: vi.fn(), + commit: vi.fn(() => Promise.resolve('committed' as const)), + dispose: vi.fn(), + getSnapshot: vi.fn(() => ({ layerId: layer.id, status: 'ready' }) as never), + interruptProcessing: vi.fn(), + process: vi.fn(() => Promise.resolve('published' as const)), + reset: vi.fn(), + setAutoProcess: vi.fn(), + subscribe: vi.fn(() => () => undefined), + updateDraft: vi.fn(), +}); + +const createHarness = () => { + const session = createSession(); + const stores = createCanvasOperationStores(); + const deps = { + captureGuard: vi.fn(() => ({ layer, layerId: layer.id }) as never), + clearOtherOperation: vi.fn(), + clearPreview: vi.fn(), + controller: { + getSnapshot: vi.fn(() => ({ status: 'idle' })), + subscribe: vi.fn(() => () => undefined), + }, + createSession: vi.fn(() => session), + getDocument: vi.fn<() => any>(() => ({ layers: [layer], selectedLayerId: null })), + getInitialDraft: vi.fn(() => ({ settings: {}, type: 'canny' })), + getSessionDeps: vi.fn(() => ({})), + isInteractionLocked: vi.fn(() => false), + selectLayer: vi.fn(), + setViewTool: vi.fn(), + stores, + }; + return { coordinator: createFilterOperationCoordinator(deps as never), deps, session, stores }; +}; + +describe('FilterOperationCoordinator', () => { + it('owns session start, publication, and cancellation', () => { + const { coordinator, deps, session, stores } = createHarness(); + + expect(coordinator.start(layer.id)).toBe('started'); + expect(deps.clearOtherOperation).toHaveBeenCalledOnce(); + expect(deps.selectLayer).toHaveBeenCalledWith(layer.id); + expect(deps.setViewTool).toHaveBeenCalledOnce(); + expect(stores.filterSession.get()).toEqual({ layerId: layer.id, status: 'ready' }); + + coordinator.cancel(); + expect(session.dispose).toHaveBeenCalledOnce(); + expect(stores.filterSession.get()).toBeNull(); + }); + + it('delegates mutation, process, and commit to the owned session', async () => { + const { coordinator, session } = createHarness(); + coordinator.start(layer.id); + + expect(coordinator.updateDraft({ settings: { strength: 1 }, type: 'canny' })).toBe('updated'); + expect(coordinator.setAutoProcess(true)).toBe('updated'); + expect(await coordinator.process()).toBe('completed'); + expect(await coordinator.commit('apply', vi.fn())).toBe('committed'); + + expect(session.updateDraft).toHaveBeenCalledOnce(); + expect(session.setAutoProcess).toHaveBeenCalledWith(true); + expect(session.process).toHaveBeenCalledOnce(); + expect(session.commit).toHaveBeenCalledWith('apply'); + }); + + it('rejects missing, unsupported, disabled, locked, and busy starts deterministically', () => { + const { coordinator, deps } = createHarness(); + deps.getDocument.mockReturnValueOnce(null); + expect(coordinator.start(layer.id)).toBe('missing'); + deps.getDocument.mockReturnValueOnce({ layers: [{ ...layer, type: 'inpaint_mask' }], selectedLayerId: null }); + expect(coordinator.start(layer.id)).toBe('unsupported'); + deps.getDocument.mockReturnValueOnce({ layers: [{ ...layer, isEnabled: false }], selectedLayerId: null }); + expect(coordinator.start(layer.id)).toBe('disabled'); + deps.getDocument.mockReturnValueOnce({ layers: [{ ...layer, isLocked: true }], selectedLayerId: null }); + expect(coordinator.start(layer.id)).toBe('locked'); + deps.isInteractionLocked.mockReturnValueOnce(true); + expect(coordinator.start(layer.id)).toBe('locked'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/FilterOperationCoordinator.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/FilterOperationCoordinator.ts new file mode 100644 index 00000000000..c0a7ca9e5f8 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/FilterOperationCoordinator.ts @@ -0,0 +1,238 @@ +import type { LayerExportGuard } from '@workbench/canvas-engine/api'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { + CanvasOperationActionResult, + CanvasOperationMutationResult, + FilterCommitOperationResult, + StartFilterOperationResult, +} from '@workbench/canvas-operations/api'; +import type { + CanvasApplicationOperationStores, + CanvasOperationController, + CanvasOperationRunResult, + CreateFilterSessionOptions, + FilterOperationSession, +} from '@workbench/canvas-operations/contracts'; +import type { FilterCommitTarget, LayerFilterSettings } from '@workbench/canvas-operations/operationTypes'; +import type { BackendGraphContract, CanvasDocumentContractV2 } from '@workbench/types'; + +import { + buildFilterDefaults, + DEFAULT_CONTROL_FILTER_TYPE, + getFilterDefinition, + isFilterConfigValid, +} from '@workbench/generation/canvas/filterGraphs'; + +import { createFilterOperationSession } from './filterOperationSession'; +import { runLayerFilter } from './layerFilterRunner'; + +export interface FilterOperationCoordinatorDeps { + readonly stores: CanvasApplicationOperationStores; + readonly controller: CanvasOperationController; + isInteractionLocked(): boolean; + getDocument(): CanvasDocumentContractV2 | null; + captureGuard(layerId: string): LayerExportGuard | null; + selectLayer(layerId: string): void; + clearOtherOperation(): void; + clearPreview(layerId: string): void; + setViewTool(): void; + encodeSurface(surface: RasterSurface): Promise; + runFilterGraph(options: { + graph: BackendGraphContract; + outputNodeId?: string; + signal?: AbortSignal; + }): Promise<{ height: number; imageName: string; width: number }>; + uploadIntermediate(blob: Blob, signal?: AbortSignal): Promise<{ imageName: string }>; + createSession?(options: CreateFilterSessionOptions): FilterOperationSession | null; + getSessionDeps( + layerId: string + ): Omit< + CreateFilterSessionOptions['deps'], + 'canCommit' | 'clearPreview' | 'controller' | 'isDraftValid' | 'makeDurable' | 'runFilter' + >; +} + +export interface FilterOperationCoordinator { + start(layerId: string, recommendedFilterType?: string | null): StartFilterOperationResult; + updateDraft(draft: LayerFilterSettings): CanvasOperationMutationResult; + setAutoProcess(value: boolean): CanvasOperationMutationResult; + process(): Promise; + reset(settings: Record): CanvasOperationMutationResult; + commit( + target: FilterCommitTarget, + makeImageDurable: (imageName: string) => Promise + ): Promise; + cancel(): void; + interruptAndBlock(): void; + isActive(): boolean; + dispose(): void; +} + +export const createFilterOperationCoordinator = (deps: FilterOperationCoordinatorDeps): FilterOperationCoordinator => { + let session: FilterOperationSession | null = null; + let unsubscribeSession: (() => void) | null = null; + let unsubscribeController: (() => void) | null = null; + let makeDurable: (imageName: string) => Promise = () => + Promise.reject(new Error('The filter result cannot be preserved.')); + + const syncStore = (): void => deps.stores.filterSession.set(session?.getSnapshot() ?? null); + const clear = (): void => { + const owned = session; + session = null; + unsubscribeSession?.(); + unsubscribeSession = null; + unsubscribeController?.(); + unsubscribeController = null; + owned?.dispose(); + deps.stores.filterSession.set(null); + }; + + const start = (layerId: string, recommendedFilterType?: string | null): StartFilterOperationResult => { + if (deps.isInteractionLocked()) { + return 'locked'; + } + if (recommendedFilterType && deps.controller.getSnapshot().status !== 'idle') { + return 'not-ready'; + } + const document = deps.getDocument(); + const layer = document?.layers.find((candidate) => candidate.id === layerId); + if (!document || !layer) { + return 'missing'; + } + if (layer.type !== 'raster' && layer.type !== 'control') { + return 'unsupported'; + } + if (recommendedFilterType && layer.filter) { + return 'not-ready'; + } + if (!layer.isEnabled) { + return 'disabled'; + } + if (layer.isLocked) { + return 'locked'; + } + const guard = deps.captureGuard(layer.id); + if (!guard) { + return 'not-ready'; + } + const initialType = layer.filter?.type ?? recommendedFilterType ?? DEFAULT_CONTROL_FILTER_TYPE; + const initialFilter = layer.filter ? structuredClone(layer.filter) : null; + const definition = getFilterDefinition(initialType); + const draft = initialFilter ?? { + settings: definition ? buildFilterDefaults(definition) : {}, + type: definition?.type ?? DEFAULT_CONTROL_FILTER_TYPE, + }; + + deps.clearOtherOperation(); + clear(); + if (document.selectedLayerId !== layer.id) { + deps.selectLayer(layer.id); + } + const createSession = deps.createSession ?? createFilterOperationSession; + session = createSession({ + deps: { + ...deps.getSessionDeps(layer.id), + canCommit: () => !deps.isInteractionLocked(), + clearPreview: () => deps.clearPreview(layer.id), + controller: deps.controller, + isDraftValid: (next) => isFilterConfigValid(next.type, next.settings), + makeDurable: (imageName) => makeDurable(imageName), + runFilter: (options) => + runLayerFilter({ + ...options, + deps: { + encodeSurface: deps.encodeSurface, + runFilterGraph: deps.runFilterGraph, + uploadIntermediate: deps.uploadIntermediate, + }, + }), + }, + guard, + initialDraft: draft, + initialFilter, + layerName: layer.name, + layerType: layer.type, + }); + if (!session) { + return 'not-ready'; + } + const installed = session; + unsubscribeSession = installed.subscribe(syncStore); + unsubscribeController = deps.controller.subscribe(() => { + if (session === installed && deps.controller.getSnapshot().status === 'idle') { + clear(); + } + }); + syncStore(); + deps.setViewTool(); + return 'started'; + }; + + return { + cancel: clear, + commit: async (target, makeImageDurable) => { + if (deps.isInteractionLocked()) { + return 'blocked'; + } + const owned = session; + if (!owned) { + return 'stale'; + } + makeDurable = makeImageDurable; + const result = await owned.commit(target); + if (result === 'committed' && session === owned) { + clear(); + } + return result; + }, + dispose: clear, + interruptAndBlock: () => { + session?.interruptProcessing(); + session?.blockCommit(); + }, + isActive: () => session !== null, + process: async () => { + if (deps.isInteractionLocked()) { + return 'blocked'; + } + if (!session) { + return 'stale'; + } + const result: CanvasOperationRunResult = await session.process(); + return result === 'published' ? 'completed' : 'stale'; + }, + reset: (settings) => { + if (deps.isInteractionLocked()) { + return 'blocked'; + } + if (!session) { + return 'stale'; + } + session.reset(settings); + return 'updated'; + }, + setAutoProcess: (value) => { + if (deps.isInteractionLocked()) { + return 'blocked'; + } + if (!session) { + return 'stale'; + } + session.setAutoProcess(value); + return 'updated'; + }, + start, + updateDraft: (draft) => { + if (deps.isInteractionLocked()) { + return 'blocked'; + } + if (!session) { + return 'stale'; + } + session.updateDraft(draft); + return 'updated'; + }, + }; +}; + +export type { FilterOperationSessionState } from '@workbench/canvas-operations/operationTypes'; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/SelectObjectCoordinator.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/SelectObjectCoordinator.ts new file mode 100644 index 00000000000..fa8c23587d3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/SelectObjectCoordinator.ts @@ -0,0 +1,437 @@ +import type { CommitGeneratedImageResult } from '@workbench/canvas-engine/api'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { SaveSelectObjectSessionResult, SelectObjectSaveTarget } from '@workbench/canvas-operations/api'; +import type { + CanvasSelectObjectCoordinatorDeps, + CanvasSelectObjectOperationCoordinator, + SelectObjectSession, + SelectObjectSessionPreview, +} from '@workbench/canvas-operations/contracts'; + +import { createSelectObjectSession } from './selectObjectSession'; + +interface CommitOwner { + controller: AbortController; + inputHash: string; + preview: SelectObjectSessionPreview; + previewId: number; + session: SelectObjectSession; + token: number; +} + +export const createSelectObjectOperationCoordinator = ( + deps: CanvasSelectObjectCoordinatorDeps +): CanvasSelectObjectOperationCoordinator => { + let session: SelectObjectSession | null = null; + let unsubscribe: (() => void) | null = null; + let controllerUnsubscribe: (() => void) | null = null; + let startContext: Extract, { status: 'ready' }> | null = null; + let publishedGuard: SelectObjectSessionPreview['guard'] | null = null; + let pointLabel: 'include' | 'exclude' = 'include'; + let commitOwner: CommitOwner | null = null; + let commitToken = 0; + + const syncStore = (): void => { + if (!session || !startContext) { + deps.stores.samSession.set(null); + return; + } + const state = session.getSnapshot(); + deps.stores.samSession.set({ + applyPolygonRefinement: state.applyPolygonRefinement, + autoProcess: state.autoProcess, + error: state.error, + hasPreview: state.preview !== null, + layerName: startContext.layerName, + layerType: startContext.layerType, + input: + state.input.type === 'visual' + ? { + bbox: state.input.bbox ? { ...state.input.bbox } : null, + excludePoints: state.input.excludePoints.map((point) => ({ ...point })), + includePoints: state.input.includePoints.map((point) => ({ ...point })), + type: 'visual', + } + : { prompt: state.input.prompt, type: 'prompt' }, + invert: state.invert, + isolatedPreview: state.isolatedPreview, + model: state.model, + pointLabel, + sourceRect: startContext.sourceRect, + status: commitOwner?.session === session ? 'committing' : state.status, + }); + deps.invalidateOverlay(); + }; + + const revokeCommit = (): void => { + commitToken += 1; + const owner = commitOwner; + commitOwner = null; + owner?.controller.abort(); + }; + + const invalidateCommit = (): void => { + revokeCommit(); + syncStore(); + }; + + const isOwnerCurrent = (owner: CommitOwner): boolean => { + const state = owner.session.getSnapshot(); + return ( + commitOwner === owner && + commitToken === owner.token && + session === owner.session && + publishedGuard === owner.preview.guard && + state.preview?.previewId === owner.previewId && + state.preview.inputHash === owner.inputHash && + deps.isGuardCurrent(owner.preview.guard) && + !owner.controller.signal.aborted + ); + }; + + const beginCommit = ( + currentSession: SelectObjectSession, + preview: SelectObjectSessionPreview + ): CommitOwner | null => { + if (commitOwner) { + return null; + } + const owner: CommitOwner = { + controller: new AbortController(), + inputHash: preview.inputHash, + preview, + previewId: preview.previewId, + session: currentSession, + token: ++commitToken, + }; + commitOwner = owner; + syncStore(); + return owner; + }; + + const finishCommit = (owner: CommitOwner): boolean => { + if (!isOwnerCurrent(owner)) { + return false; + } + commitOwner = null; + syncStore(); + return true; + }; + + const clear = (expectedOwner?: CommitOwner): boolean => { + if (expectedOwner && (!isOwnerCurrent(expectedOwner) || session !== expectedOwner.session)) { + return false; + } + deps.replaceTemporaryRestoreTool(); + const previous = session; + session = null; + unsubscribe?.(); + unsubscribe = null; + controllerUnsubscribe?.(); + controllerUnsubscribe = null; + startContext = null; + publishedGuard = null; + pointLabel = 'include'; + revokeCommit(); + deps.clearPreview(); + deps.controller.cancel(); + previous?.dispose(); + deps.stores.samSession.set(null); + deps.invalidateOverlay(); + return true; + }; + + const currentPreview = (): SelectObjectSessionPreview | null => { + const state = session?.getSnapshot(); + const preview = state?.preview; + if ( + !state || + (state.status !== 'ready' && state.status !== 'error') || + !preview || + preview.guard !== publishedGuard + ) { + return null; + } + return deps.isGuardCurrent(preview.guard) ? preview : null; + }; + + const reportResultError = (owner: CommitOwner, result: SaveSelectObjectSessionResult): void => { + if (!finishCommit(owner)) { + return; + } + owner.session.reportError( + result.status === 'locked' + ? { code: 'locked' } + : { + code: 'unknown', + detail: result.status === 'failed' ? result.message : `Select Object commit is ${result.status}.`, + } + ); + }; + + const makeDurable = async ( + owner: CommitOwner, + callback: (imageName: string) => Promise + ): Promise<{ status: 'ok' } | { status: 'failed'; message: string }> => { + try { + await callback(owner.preview.image.imageName); + return { status: 'ok' }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (finishCommit(owner)) { + owner.session.reportError({ code: 'unknown', detail: message }); + } + return { message, status: 'failed' }; + } + }; + + return { + start: (layerId) => { + if (deps.isInteractionLocked()) { + return 'locked'; + } + const prepared = deps.prepareStart(layerId); + if (prepared.status !== 'ready') { + return prepared.status; + } + deps.clearOtherOperation(); + clear(); + startContext = prepared; + deps.selectLayer(layerId); + const operation = deps.controller.start({ + cleanupPreview: deps.clearPreview, + guard: prepared.guard, + identity: { kind: 'select-object', layerId, projectId: deps.projectId }, + }); + if (!operation) { + startContext = null; + return 'not-ready'; + } + const createSession = deps.createSession ?? createSelectObjectSession; + session = createSession({ + deps: { + captureGuard: () => deps.captureGuard(layerId), + cleanupPreview: deps.clearPreview, + controller: deps.controller, + decodePreview: deps.decodePreview, + exportSource: () => deps.exportSource(layerId), + isGuardCurrent: deps.isGuardCurrent, + publishPreview: (preview) => { + publishedGuard = preview.guard; + deps.publishPreview(preview); + return undefined; + }, + runGraph: deps.runGraph, + uploadIntermediate: deps.uploadIntermediate, + }, + operation, + }); + const installed = session; + unsubscribe = installed.subscribe(() => { + if ( + commitOwner?.session === installed && + installed.getSnapshot().preview?.previewId !== commitOwner.previewId + ) { + invalidateCommit(); + } + syncStore(); + }); + controllerUnsubscribe = deps.controller.subscribe(() => { + if (session === installed && deps.controller.getSnapshot().status === 'idle') { + clear(); + if (deps.isSamToolActive()) { + deps.setViewTool(); + } + } + }); + installed.update({ input: { bbox: null, excludePoints: [], includePoints: [], type: 'visual' } }); + syncStore(); + deps.setSamTool(); + return 'started'; + }, + update: (changes) => { + if (deps.isInteractionLocked()) { + return 'blocked'; + } + if (!session) { + return 'stale'; + } + const { pointLabel: nextPointLabel, ...sessionChanges } = changes; + const state = session.getSnapshot(); + const processingChanged = + ('input' in sessionChanges && sessionChanges.input !== state.input) || + ('model' in sessionChanges && sessionChanges.model !== state.model) || + ('invert' in sessionChanges && sessionChanges.invert !== state.invert) || + ('applyPolygonRefinement' in sessionChanges && + sessionChanges.applyPolygonRefinement !== state.applyPolygonRefinement); + if (processingChanged) { + invalidateCommit(); + } + if (nextPointLabel) { + pointLabel = nextPointLabel; + } + session.update(sessionChanges); + syncStore(); + return 'updated'; + }, + process: () => { + if (deps.isInteractionLocked()) { + return Promise.resolve('blocked'); + } + invalidateCommit(); + return session?.process() ?? Promise.resolve('stale'); + }, + apply: async (durable): Promise => { + if (deps.isInteractionLocked()) { + return { status: 'locked' }; + } + const currentSession = session; + const preview = currentPreview(); + if (!currentSession || !preview) { + return { status: 'stale' }; + } + const owner = beginCommit(currentSession, preview); + if (!owner) { + return { status: 'busy' }; + } + if (!isOwnerCurrent(owner)) { + invalidateCommit(); + return { status: 'stale' }; + } + const durability = await makeDurable(owner, durable); + if (durability.status === 'failed') { + return durability; + } + if (!isOwnerCurrent(owner)) { + return deps.isInteractionLocked() ? { status: 'locked' } : { status: 'stale' }; + } + if (deps.isInteractionLocked()) { + invalidateCommit(); + return { status: 'locked' }; + } + let result: CommitGeneratedImageResult; + try { + result = await deps.commitGenerated(preview, { mode: 'replace', signal: owner.controller.signal }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (finishCommit(owner)) { + owner.session.reportError({ code: 'unknown', detail: message }); + } + return { message, status: 'failed' }; + } + if (!isOwnerCurrent(owner)) { + return result; + } + if (result.status === 'committed') { + clear(owner); + if (deps.isSamToolActive()) { + deps.setViewTool(); + } + } else { + reportResultError(owner, result); + } + return result; + }, + save: async (target: SelectObjectSaveTarget, durable): Promise => { + if (deps.isInteractionLocked()) { + return { status: 'locked' }; + } + const currentSession = session; + const preview = currentPreview(); + if (!currentSession || !preview) { + return { status: 'stale' }; + } + const owner = beginCommit(currentSession, preview); + if (!owner) { + return { status: 'busy' }; + } + if (target === 'selection') { + const result = await deps.replaceSelection(preview, owner.controller.signal); + if (!isOwnerCurrent(owner)) { + return result; + } + if (result.status === 'selected') { + clear(owner); + if (deps.isSamToolActive()) { + deps.setViewTool(); + } + } else { + reportResultError(owner, result); + } + return result; + } + const durability = await makeDurable(owner, durable); + if (durability.status === 'failed') { + return durability; + } + if (!isOwnerCurrent(owner)) { + return deps.isInteractionLocked() ? { status: 'locked' } : { status: 'stale' }; + } + if (deps.isInteractionLocked()) { + invalidateCommit(); + return { status: 'locked' }; + } + let result: SaveSelectObjectSessionResult; + try { + result = + target === 'raster' || target === 'control' + ? await deps.commitGenerated(preview, { + mode: target === 'raster' ? 'copy-raster' : 'copy-control', + signal: owner.controller.signal, + }) + : await deps.commitMask(preview, target, owner.controller.signal); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (finishCommit(owner)) { + owner.session.reportError({ code: 'unknown', detail: message }); + } + return { message, status: 'failed' }; + } + if (!isOwnerCurrent(owner)) { + return result; + } + if (result.status === 'committed') { + finishCommit(owner); + } else { + reportResultError(owner, result); + } + return result; + }, + reset: () => { + if (deps.isInteractionLocked()) { + return 'blocked'; + } + const operation = deps.controller.getSnapshot(); + if ( + !session || + !startContext || + !deps.isGuardCurrent(startContext.guard) || + operation.status !== 'active' || + operation.identity.kind !== 'select-object' || + operation.identity.projectId !== deps.projectId || + operation.identity.layerId !== startContext.layerId + ) { + return 'stale'; + } + invalidateCommit(); + publishedGuard = null; + pointLabel = 'include'; + session.reset(); + session.update({ input: { bbox: null, excludePoints: [], includePoints: [], type: 'visual' } }); + syncStore(); + return 'updated'; + }, + cancel: () => { + clear(); + if (deps.isSamToolActive()) { + deps.setViewTool(); + } + }, + interruptAndBlock: () => { + invalidateCommit(); + session?.interruptProcessing(); + }, + isActive: () => session !== null, + dispose: () => clear(), + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/api.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/api.ts new file mode 100644 index 00000000000..44bbb4210d7 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/api.ts @@ -0,0 +1,77 @@ +import type { CommitGeneratedImageResult, ReplaceSelectionFromImageResult } from '@workbench/canvas-engine/api'; +import type { CommitRasterFilterResult } from '@workbench/canvas-engine/controllers/filterResultController'; +import type { + CommitMaskImageResult, + MaskImageResultTarget, +} from '@workbench/canvas-engine/controllers/maskResultController'; +import type { + CanvasApplicationOperationStores, + CanvasOperationController, + SelectObjectSessionProcessResult, +} from '@workbench/canvas-operations/contracts'; +import type { + FilterCommitTarget, + LayerFilterSettings, + SamInput, + SamModel, + SamPointLabel, +} from '@workbench/canvas-operations/operationTypes'; + +export type StartSelectObjectSessionResult = + | 'started' + | 'missing' + | 'disabled' + | 'locked' + | 'unsupported' + | 'not-ready'; +export type StartFilterOperationResult = 'started' | 'missing' | 'disabled' | 'locked' | 'unsupported' | 'not-ready'; +export type CanvasOperationActionResult = 'completed' | 'blocked' | 'stale'; +export type FilterCommitOperationResult = 'committed' | 'blocked' | 'stale'; +export type CanvasOperationMutationResult = 'updated' | 'blocked' | 'stale'; +export type SelectObjectSaveTarget = 'selection' | 'raster' | 'control' | MaskImageResultTarget; +export type SaveSelectObjectSessionResult = + | CommitGeneratedImageResult + | CommitMaskImageResult + | ReplaceSelectionFromImageResult; + +export interface SelectObjectSessionUpdate { + input?: SamInput; + pointLabel?: SamPointLabel; + model?: SamModel; + invert?: boolean; + applyPolygonRefinement?: boolean; + autoProcess?: boolean; + isolatedPreview?: boolean; +} + +export interface CanvasOperationCapability { + readonly controller: CanvasOperationController; + readonly stores: CanvasApplicationOperationStores; + startSelectObject(layerId: string): StartSelectObjectSessionResult; + startFilterOperation(layerId: string, recommendedFilterType?: string | null): StartFilterOperationResult; + updateFilterOperation(draft: LayerFilterSettings): CanvasOperationMutationResult; + setFilterOperationAutoProcess(value: boolean): CanvasOperationMutationResult; + processFilterOperation(): Promise; + resetFilterOperation(settings: Record): CanvasOperationMutationResult; + commitFilterOperation( + target: FilterCommitTarget, + makeImageDurable: (imageName: string) => Promise + ): Promise; + cancelFilterOperation(): void; + updateSelectObjectSession(changes: SelectObjectSessionUpdate): CanvasOperationMutationResult; + processSelectObjectSession(): Promise; + applySelectObjectSession(makeImageDurable: (imageName: string) => Promise): Promise; + saveSelectObjectSession( + target: SelectObjectSaveTarget, + makeImageDurable: (imageName: string) => Promise + ): Promise; + resetSelectObjectSession(): CanvasOperationMutationResult; + cancelSelectObjectSession(): void; +} + +export type { CommitRasterFilterResult }; +export { + importGalleryImagesToCanvas, + type GalleryCanvasImportDestination, + type ImportGalleryImagesResult, +} from './importGalleryImages'; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/applicationPort.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/applicationPort.ts new file mode 100644 index 00000000000..35936f41235 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/applicationPort.ts @@ -0,0 +1,26 @@ +import type { CanvasApplicationPort } from '@workbench/canvas-operations/contracts'; + +import { socketHub } from '@workbench/backend/socketHub'; +import { uploadCanvasImage } from '@workbench/canvas-operations/backend/canvasImages'; +import { runUtilityGraph } from '@workbench/canvas-operations/backend/utilityQueue'; +import { + createFilterOperationCoordinator, + type FilterOperationCoordinatorDeps, +} from '@workbench/canvas-operations/FilterOperationCoordinator'; +import { createCanvasOperationController } from '@workbench/canvas-operations/operationController'; +import { createCanvasOperationStores } from '@workbench/canvas-operations/operationStores'; +import { createSelectObjectOperationCoordinator } from '@workbench/canvas-operations/SelectObjectCoordinator'; +import { getSelectedModelBase } from '@workbench/widgets/layers/selectedModel'; + +export const canvasApplicationPort: CanvasApplicationPort = { + createFilterCoordinator: (deps) => createFilterOperationCoordinator(deps as FilterOperationCoordinatorDeps), + createOperationController: (deps) => createCanvasOperationController(deps), + createOperationStores: createCanvasOperationStores, + createSelectObjectCoordinator: createSelectObjectOperationCoordinator, + getSelectedModelBase: (state, projectId) => { + const project = state.projects.find((candidate) => candidate.id === projectId); + return project ? getSelectedModelBase(project) : null; + }, + runGraph: (options) => runUtilityGraph({ ...options, hub: socketHub }), + uploadImage: (blob, options) => uploadCanvasImage(blob, options), +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/backend/canvasImages.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/backend/canvasImages.test.ts new file mode 100644 index 00000000000..e9c0ad8158d --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/backend/canvasImages.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CanvasImageUploadError, uploadCanvasImage } from './canvasImages'; + +/** A minimal `Response`-shaped fake for the injected fetch. */ +const jsonResponse = (body: unknown, init: { ok?: boolean; status?: number; statusText?: string } = {}): Response => + ({ + json: () => Promise.resolve(body), + ok: init.ok ?? true, + status: init.status ?? 200, + statusText: init.statusText ?? 'OK', + text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)), + }) as unknown as Response; + +describe('uploadCanvasImage', () => { + it('forwards an abort signal to fetch', async () => { + const controller = new AbortController(); + const fetchImpl = vi.fn(() => + Promise.resolve(jsonResponse({ height: 1, image_name: 'uploaded.png', width: 1 })) + ) as unknown as typeof fetch; + + await uploadCanvasImage(new Blob(['pixels'], { type: 'image/png' }), { + fetch: fetchImpl, + signal: controller.signal, + }); + + expect(fetchImpl).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ signal: controller.signal })); + }); + + it('POSTs a multipart file to the upload endpoint with the persistence params', async () => { + const fetchImpl = vi.fn(() => + Promise.resolve(jsonResponse({ height: 64, image_name: 'paint-1.png', width: 128 }, { status: 201 })) + ); + const blob = new Blob(['png-bytes'], { type: 'image/png' }); + + const result = await uploadCanvasImage(blob, { fetch: fetchImpl }); + + expect(result).toEqual({ height: 64, imageName: 'paint-1.png', width: 128 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toContain('/api/v1/images/upload'); + expect(url).toContain('image_category=other'); + expect(url).toContain('is_intermediate=false'); + expect(init.method).toBe('POST'); + expect(init.body).toBeInstanceOf(FormData); + const file = (init.body as FormData).get('file'); + expect(file).toBeInstanceOf(File); + expect((file as File).type).toBe('image/png'); + }); + + it('honors overrides for category, intermediate flag, board, metadata, and resize dimensions', async () => { + const fetchImpl = vi.fn(() => + Promise.resolve(jsonResponse({ height: 1, image_name: 'x', width: 1 }, { status: 201 })) + ); + + await uploadCanvasImage(new Blob([''], { type: 'image/png' }), { + boardId: 'board-9', + fetch: fetchImpl, + imageCategory: 'user', + isIntermediate: true, + metadata: { seed: 42 }, + resizeTo: { width: 1024, height: 768 }, + }); + + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toContain('image_category=user'); + expect(url).toContain('is_intermediate=true'); + expect(url).toContain('board_id=board-9'); + const formData = init.body as FormData; + expect(formData.get('metadata')).toBe(JSON.stringify({ seed: 42 })); + expect(formData.get('resize_to')).toBe(JSON.stringify({ width: 1024, height: 768 })); + }); + + it('throws a typed error with the status on a non-ok response', async () => { + const fetchImpl = vi.fn(() => + Promise.resolve(jsonResponse('Not an image', { ok: false, status: 415, statusText: 'Unsupported Media Type' })) + ); + + await expect(uploadCanvasImage(new Blob([''], { type: 'image/png' }), { fetch: fetchImpl })).rejects.toMatchObject({ + name: 'CanvasImageUploadError', + status: 415, + }); + }); + + it('wraps a network failure in a CanvasImageUploadError', async () => { + const fetchImpl = vi.fn(() => Promise.reject(new Error('network down'))); + + const error = await uploadCanvasImage(new Blob([''], { type: 'image/png' }), { fetch: fetchImpl }).catch( + (caught: unknown) => caught + ); + + expect(error).toBeInstanceOf(CanvasImageUploadError); + expect((error as CanvasImageUploadError).status).toBeNull(); + expect((error as CanvasImageUploadError).message).toContain('network down'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/backend/canvasImages.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/backend/canvasImages.ts new file mode 100644 index 00000000000..a2ae692bdeb --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/backend/canvasImages.ts @@ -0,0 +1,124 @@ +/** + * Uploads canvas paint bitmaps to the backend as persistent, non-gallery + * images. Paint layers reference their pixels by `imageName` (never by URL or + * inline data), so the persisted workbench document — which autosaves to + * localStorage (~5 MB) — stays ref-only and the pixels live server-side. + * + * `image_category='other'` + `is_intermediate=false` keeps the bitmap durable + * (not garbage-collected as an intermediate) while hiding it from the gallery's + * general/asset views. + * + * The `fetch` seam is injectable so this runs in node tests without a DOM. + * Auth + base-URL resolution mirror the shared HTTP client so uploads carry the + * same bearer token as every other authenticated request. Zero React. + */ + +import type { CanvasImageUploadResult } from '@workbench/canvas-engine/document/imageUpload'; + +import { buildApiUrl, getAuthToken } from '@workbench/backend/http'; + +export type { CanvasImageUploadResult } from '@workbench/canvas-engine/document/imageUpload'; + +/** The subset of the backend `ImageDTO` this module reads. */ +interface UploadImageResponseDTO { + image_name: string; + width: number; + height: number; +} + +/** Options for {@link uploadCanvasImage}. */ +export interface UploadCanvasImageOptions { + /** Overrides the default `'other'` category. */ + imageCategory?: 'other' | 'general' | 'control' | 'mask' | 'user'; + /** Overrides the default `false` (persistent, not garbage-collected). */ + isIntermediate?: boolean; + /** Adds the image to a board, if given. */ + boardId?: string; + /** File name sent in the multipart part (defaults to `canvas-paint.png`). */ + fileName?: string; + /** Optional image metadata sent as JSON in the multipart body. */ + metadata?: Record; + /** Optional backend resize dimensions sent as JSON in the multipart body. */ + resizeTo?: { width: number; height: number }; + /** Injectable `fetch` implementation (defaults to the global). */ + fetch?: typeof globalThis.fetch; + /** Cancels the multipart request when its owning operation is superseded. */ + signal?: AbortSignal; +} + +/** Thrown when a canvas-image upload fails (non-2xx or network error). */ +export class CanvasImageUploadError extends Error { + readonly status: number | null; + + constructor(message: string, status: number | null) { + super(message); + this.name = 'CanvasImageUploadError'; + this.status = status; + } +} + +/** + * POSTs `blob` as a PNG to `/api/v1/images/upload` (multipart `file` field) and + * returns the server-assigned image name and dimensions. Throws + * {@link CanvasImageUploadError} on any non-success response. + */ +export const uploadCanvasImage = async ( + blob: Blob, + options: UploadCanvasImageOptions = {} +): Promise => { + const fetchImpl = options.fetch ?? globalThis.fetch; + + const query = new URLSearchParams({ + image_category: options.imageCategory ?? 'other', + is_intermediate: String(options.isIntermediate ?? false), + }); + if (options.boardId) { + query.set('board_id', options.boardId); + } + + const fileName = options.fileName ?? 'canvas-paint.png'; + const file = new File([blob], fileName, { type: blob.type || 'image/png' }); + const body = new FormData(); + body.append('file', file); + if (options.metadata) { + body.append('metadata', JSON.stringify(options.metadata)); + } + if (options.resizeTo) { + body.append('resize_to', JSON.stringify(options.resizeTo)); + } + + const headers = new Headers(); + const token = getAuthToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + let response: Response; + try { + response = await fetchImpl(buildApiUrl(`/api/v1/images/upload?${query.toString()}`), { + body, + headers, + method: 'POST', + signal: options.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error; + } + throw new CanvasImageUploadError( + `Canvas image upload failed: ${error instanceof Error ? error.message : String(error)}`, + null + ); + } + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new CanvasImageUploadError( + text || `Canvas image upload failed: ${response.status} ${response.statusText}`, + response.status + ); + } + + const dto = (await response.json()) as UploadImageResponseDTO; + return { height: dto.height, imageName: dto.image_name, width: dto.width }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/backend/utilityQueue.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/backend/utilityQueue.test.ts new file mode 100644 index 00000000000..baaaaca7994 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/backend/utilityQueue.test.ts @@ -0,0 +1,1120 @@ +import type { InvocationCompleteEvent, QueueItemStatusChangedEvent } from '@workbench/backend/events'; +import type { SocketHub } from '@workbench/backend/socketHub'; + +import { + buildQueueItemOrigin, + buildUtilityQueueItemOrigin, + isUtilityQueueItemOrigin, + parseQueueItemOrigin, +} from '@workbench/backend/events'; +import { ApiError } from '@workbench/backend/http'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { UtilityEnqueue } from './utilityQueue'; + +import { DEFAULT_UTILITY_QUEUE_TIMEOUT_MS, runUtilityGraph, UtilityQueueError } from './utilityQueue'; + +/** A fake socket hub that records handlers per event and can emit to them, mirroring `hub.on`. */ +const createFakeHub = () => { + const handlers = new Map void>>(); + let detachCount = 0; + + const hub: Pick = { + on: (event, handler) => { + const set = handlers.get(event) ?? new Set(); + set.add(handler as (payload: unknown) => void); + handlers.set(event, set); + return () => { + detachCount += 1; + set.delete(handler as (payload: unknown) => void); + }; + }, + }; + + const emit = (event: string, payload: unknown): void => { + for (const handler of handlers.get(event) ?? []) { + handler(payload); + } + }; + + return { + emit, + get detachCount() { + return detachCount; + }, + handlerCount: (event: string): number => handlers.get(event)?.size ?? 0, + hub, + }; +}; + +const UTIL_ID = 'fixed-util-id'; +const ORIGIN = buildUtilityQueueItemOrigin(UTIL_ID); + +const completeEvent = (overrides: Partial = {}): InvocationCompleteEvent => ({ + batch_id: 'b', + destination: null, + invocation_source_id: 'control_filter', + item_id: 1, + origin: ORIGIN, + queue_id: 'default', + result: { height: 48, image: { image_name: 'filtered.png' }, type: 'image_output', width: 64 }, + session_id: 's', + ...overrides, +}); + +const statusEvent = ( + status: QueueItemStatusChangedEvent['status'], + overrides: Partial = {} +): QueueItemStatusChangedEvent => ({ + batch_id: 'b', + completed_at: null, + created_at: '', + destination: null, + error_message: null, + error_traceback: null, + error_type: null, + item_id: 1, + origin: ORIGIN, + queue_id: 'default', + session_id: 's', + started_at: null, + status, + updated_at: '', + ...overrides, +}); + +const okEnqueue: UtilityEnqueue = () => Promise.resolve({ enqueued: 1, itemIds: [1] }); + +const createDeferred = (): { promise: Promise; resolve: (value: T) => void } => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +const createReconcileScheduler = () => { + const pending: Array<{ callback: () => void; canceled: boolean }> = []; + const cancelers: ReturnType[] = []; + const schedule = vi.fn((callback: () => void, _delayMs: number) => { + const entry = { callback, canceled: false }; + pending.push(entry); + const cancel = vi.fn(() => { + entry.canceled = true; + }); + cancelers.push(cancel); + return cancel; + }); + return { + cancelers, + pending, + runNext() { + const entry = pending.shift(); + if (!entry) { + throw new Error('No reconciliation retry is scheduled.'); + } + if (!entry.canceled) { + entry.callback(); + } + }, + schedule, + }; +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('runUtilityGraph — await + settle', () => { + it('resolves with the captured image name and the isolated origin on completion', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + outputNodeId: 'control_filter', + }); + + // Listeners are attached before enqueue resolves (fast-finish race closed). + expect(fake.handlerCount('invocation_complete')).toBe(1); + expect(fake.handlerCount('queue_item_status_changed')).toBe(1); + + fake.emit('invocation_complete', completeEvent()); + fake.emit('queue_item_status_changed', statusEvent('completed')); + + await expect(promise).resolves.toEqual({ height: 48, imageName: 'filtered.png', origin: ORIGIN, width: 64 }); + // Both listeners detached on settle. + expect(fake.handlerCount('invocation_complete')).toBe(0); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + expect(fake.detachCount).toBe(2); + }); + + it('ignores completions from other output nodes when outputNodeId is set', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + outputNodeId: 'control_filter', + }); + + // A different node's completion is ignored; the target node's is captured. + fake.emit( + 'invocation_complete', + completeEvent({ + invocation_source_id: 'other', + result: { image: { image_name: 'wrong.png' }, type: 'image_output' }, + }) + ); + fake.emit('invocation_complete', completeEvent()); + fake.emit('queue_item_status_changed', statusEvent('completed')); + + await expect(promise).resolves.toMatchObject({ imageName: 'filtered.png' }); + }); + + it('allows delayed socket output to win after a successful null reconciliation', async () => { + const fake = createFakeHub(); + const scheduler = createReconcileScheduler(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + reconcileCompletedOutput: () => Promise.resolve(null), + scheduleReconcileRetry: scheduler.schedule, + }); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await vi.waitFor(() => expect(scheduler.schedule).toHaveBeenCalledOnce()); + + fake.emit('invocation_complete', completeEvent()); + + await expect(promise).resolves.toMatchObject({ imageName: 'filtered.png' }); + expect(scheduler.cancelers[0]).toHaveBeenCalledOnce(); + }); + + it('reconciles the target output when completed status arrives before invocation_complete', async () => { + const fake = createFakeHub(); + const reconcileCompletedOutput = vi.fn(() => + Promise.resolve({ height: 48, imageName: 'reconciled.png', width: 64 }) + ); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + outputNodeId: 'control_filter', + reconcileCompletedOutput, + }); + + fake.emit('queue_item_status_changed', statusEvent('completed')); + + await expect(promise).resolves.toEqual({ + height: 48, + imageName: 'reconciled.png', + origin: ORIGIN, + width: 64, + }); + expect(reconcileCompletedOutput).toHaveBeenCalledExactlyOnceWith([1], 'control_filter'); + expect(fake.handlerCount('invocation_complete')).toBe(0); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + }); + + it('resolves when a later reconciliation finds output after an initial successful null', async () => { + const fake = createFakeHub(); + const scheduler = createReconcileScheduler(); + const reconcileCompletedOutput = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ height: 48, imageName: 'later.png', width: 64 }); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + outputNodeId: 'control_filter', + reconcileCompletedOutput, + scheduleReconcileRetry: scheduler.schedule, + }); + + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + + await vi.waitFor(() => expect(scheduler.schedule).toHaveBeenCalledOnce()); + scheduler.runNext(); + + await expect(promise).resolves.toMatchObject({ imageName: 'later.png' }); + expect(reconcileCompletedOutput).toHaveBeenCalledTimes(2); + }); + + it('rejects no-output only after exhausting successful null reconciliations', async () => { + const fake = createFakeHub(); + const scheduler = createReconcileScheduler(); + const reconcileCompletedOutput = vi.fn(() => Promise.resolve(null)); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + reconcileCompletedOutput, + reconcileRetryPolicy: { delayMs: 10, maxAttempts: 3 }, + scheduleReconcileRetry: scheduler.schedule, + }); + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await vi.waitFor(() => expect(scheduler.schedule).toHaveBeenCalledTimes(1)); + scheduler.runNext(); + await vi.waitFor(() => expect(scheduler.schedule).toHaveBeenCalledTimes(2)); + scheduler.runNext(); + + await expect(promise).rejects.toMatchObject({ reason: 'no-output' }); + expect(reconcileCompletedOutput).toHaveBeenCalledTimes(3); + }); + + it('aborts and fully cleans up while completed-output reconciliation is pending', async () => { + const fake = createFakeHub(); + const reconciliation = createDeferred<{ height: number; imageName: string; width: number } | null>(); + const controller = new AbortController(); + const cancel = vi.fn(() => Promise.resolve()); + const promise = runUtilityGraph({ + cancel, + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + outputNodeId: 'control_filter', + reconcileCompletedOutput: () => reconciliation.promise, + signal: controller.signal, + }); + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + + controller.abort(); + + await expect(promise).rejects.toMatchObject({ reason: 'aborted' }); + await vi.waitFor(() => expect(cancel).toHaveBeenCalledExactlyOnceWith([1])); + expect(fake.handlerCount('invocation_complete')).toBe(0); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + reconciliation.resolve({ height: 48, imageName: 'late.png', width: 64 }); + await Promise.resolve(); + expect(fake.detachCount).toBe(2); + }); + + it('allows delayed socket output to win after a transient reconciliation failure', async () => { + const fake = createFakeHub(); + const scheduler = createReconcileScheduler(); + const reconcileCompletedOutput = vi.fn(() => Promise.reject(new TypeError('gateway unavailable'))); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + outputNodeId: 'control_filter', + reconcileCompletedOutput, + scheduleReconcileRetry: scheduler.schedule, + }); + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await vi.waitFor(() => expect(scheduler.schedule).toHaveBeenCalledOnce()); + + fake.emit('invocation_complete', completeEvent()); + + await expect(promise).resolves.toMatchObject({ imageName: 'filtered.png' }); + expect(reconcileCompletedOutput).toHaveBeenCalledOnce(); + expect(scheduler.cancelers[0]).toHaveBeenCalledOnce(); + expect(fake.handlerCount('invocation_complete')).toBe(0); + }); + + it('retries bounded reconciliation failures and resolves from a later successful attempt', async () => { + const fake = createFakeHub(); + const scheduler = createReconcileScheduler(); + const reconcileCompletedOutput = vi + .fn() + .mockRejectedValueOnce(new TypeError('first failure')) + .mockRejectedValueOnce(new TypeError('second failure')) + .mockResolvedValueOnce({ height: 48, imageName: 'reconciled.png', width: 64 }); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + outputNodeId: 'control_filter', + reconcileCompletedOutput, + reconcileRetryPolicy: { delayMs: 25, maxAttempts: 3 }, + scheduleReconcileRetry: scheduler.schedule, + }); + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await vi.waitFor(() => expect(scheduler.schedule).toHaveBeenCalledTimes(1)); + scheduler.runNext(); + await vi.waitFor(() => expect(scheduler.schedule).toHaveBeenCalledTimes(2)); + scheduler.runNext(); + + await expect(promise).resolves.toMatchObject({ imageName: 'reconciled.png' }); + expect(reconcileCompletedOutput).toHaveBeenCalledTimes(3); + expect(scheduler.schedule).toHaveBeenNthCalledWith(1, expect.any(Function), 25); + }); + + it('rejects with reconcile reason and preserves the final failure after bounded attempts are exhausted', async () => { + const fake = createFakeHub(); + const scheduler = createReconcileScheduler(); + const finalCause = new TypeError('connection failed'); + const reconcileCompletedOutput = vi + .fn() + .mockRejectedValueOnce(new TypeError('temporary failure')) + .mockRejectedValueOnce(finalCause); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + reconcileCompletedOutput, + reconcileRetryPolicy: { delayMs: 10, maxAttempts: 2 }, + scheduleReconcileRetry: scheduler.schedule, + }); + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await vi.waitFor(() => expect(scheduler.schedule).toHaveBeenCalledOnce()); + scheduler.runNext(); + + await expect(promise).rejects.toMatchObject({ + cause: finalCause, + message: 'Failed to reconcile utility graph output after 2 attempts: connection failed', + reason: 'reconcile', + }); + expect(fake.handlerCount('invocation_complete')).toBe(0); + }); + + it('aborts during a scheduled reconciliation retry and cancels the retry timer', async () => { + const fake = createFakeHub(); + const scheduler = createReconcileScheduler(); + const controller = new AbortController(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + reconcileCompletedOutput: () => Promise.reject(new TypeError('offline')), + scheduleReconcileRetry: scheduler.schedule, + signal: controller.signal, + }); + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await vi.waitFor(() => expect(scheduler.schedule).toHaveBeenCalledOnce()); + + controller.abort(); + + await expect(promise).rejects.toMatchObject({ reason: 'aborted' }); + expect(scheduler.cancelers[0]).toHaveBeenCalledOnce(); + scheduler.runNext(); + expect(fake.handlerCount('invocation_complete')).toBe(0); + }); + + it('cancels a scheduled reconciliation retry when terminal failure wins', async () => { + const fake = createFakeHub(); + const scheduler = createReconcileScheduler(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + reconcileCompletedOutput: () => Promise.reject(new TypeError('offline')), + scheduleReconcileRetry: scheduler.schedule, + }); + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await vi.waitFor(() => expect(scheduler.schedule).toHaveBeenCalledOnce()); + + fake.emit('queue_item_status_changed', statusEvent('failed', { error_message: 'backend failed' })); + + await expect(promise).rejects.toMatchObject({ message: 'backend failed', reason: 'failed' }); + expect(scheduler.cancelers[0]).toHaveBeenCalledOnce(); + expect(fake.handlerCount('invocation_complete')).toBe(0); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + }); + + it('fails an authentication reconciliation error immediately without retrying', async () => { + const fake = createFakeHub(); + const scheduler = createReconcileScheduler(); + const cause = new ApiError('unauthorized', 401); + const reconcileCompletedOutput = vi.fn(() => Promise.reject(cause)); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + reconcileCompletedOutput, + scheduleReconcileRetry: scheduler.schedule, + }); + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + + await expect(promise).rejects.toMatchObject({ cause, reason: 'reconcile' }); + expect(reconcileCompletedOutput).toHaveBeenCalledOnce(); + expect(scheduler.schedule).not.toHaveBeenCalled(); + }); + + it('retries a transient 503 reconciliation error', async () => { + const fake = createFakeHub(); + const scheduler = createReconcileScheduler(); + const reconcileCompletedOutput = vi + .fn() + .mockRejectedValueOnce(new ApiError('unavailable', 503)) + .mockResolvedValueOnce({ height: 48, imageName: 'recovered.png', width: 64 }); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + reconcileCompletedOutput, + scheduleReconcileRetry: scheduler.schedule, + }); + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await vi.waitFor(() => expect(scheduler.schedule).toHaveBeenCalledOnce()); + scheduler.runNext(); + + await expect(promise).resolves.toMatchObject({ imageName: 'recovered.png' }); + expect(reconcileCompletedOutput).toHaveBeenCalledTimes(2); + }); + + it('fails malformed protocol data immediately without retrying', async () => { + const fake = createFakeHub(); + const scheduler = createReconcileScheduler(); + const cause = new Error('Queue item 1 returned malformed reconciliation data.'); + const reconcileCompletedOutput = vi.fn(() => Promise.reject(cause)); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + reconcileCompletedOutput, + scheduleReconcileRetry: scheduler.schedule, + }); + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + + await expect(promise).rejects.toMatchObject({ cause, reason: 'reconcile' }); + expect(reconcileCompletedOutput).toHaveBeenCalledOnce(); + expect(scheduler.schedule).not.toHaveBeenCalled(); + }); + + it('captures a synchronous reconciler throw triggered after enqueue completion', async () => { + const fake = createFakeHub(); + const enqueue = createDeferred<{ enqueued: number; itemIds: number[] }>(); + const cause = new Error('sync after enqueue'); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => enqueue.promise, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + reconcileCompletedOutput: () => { + throw cause; + }, + }); + fake.emit('queue_item_status_changed', statusEvent('completed')); + + enqueue.resolve({ enqueued: 1, itemIds: [1] }); + + await expect(promise).rejects.toMatchObject({ cause, reason: 'reconcile' }); + }); + + it('captures a synchronous reconciler throw triggered from the socket callback', async () => { + const fake = createFakeHub(); + const cause = new Error('sync from socket'); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + reconcileCompletedOutput: () => { + throw cause; + }, + }); + await Promise.resolve(); + + expect(() => fake.emit('queue_item_status_changed', statusEvent('completed'))).not.toThrow(); + await expect(promise).rejects.toMatchObject({ cause, reason: 'reconcile' }); + }); + + it('captures a synchronous reconciler throw triggered from the retry timer', async () => { + const fake = createFakeHub(); + const scheduler = createReconcileScheduler(); + const cause = new Error('sync from retry'); + const reconcileCompletedOutput = vi + .fn() + .mockRejectedValueOnce(new TypeError('offline')) + .mockImplementationOnce(() => { + throw cause; + }); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + reconcileCompletedOutput, + scheduleReconcileRetry: scheduler.schedule, + }); + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await vi.waitFor(() => expect(scheduler.schedule).toHaveBeenCalledOnce()); + + expect(() => scheduler.runNext()).not.toThrow(); + await expect(promise).rejects.toMatchObject({ cause, reason: 'reconcile' }); + }); + + it('rejects with the failure reason + message on a failed item', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + fake.emit('queue_item_status_changed', statusEvent('failed', { error_message: 'boom' })); + await expect(promise).rejects.toMatchObject({ message: 'boom', reason: 'failed' }); + }); + + it.each(['', ' '])('uses a trimmed backend error type when the message is %j', async (error_message) => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + + fake.emit( + 'queue_item_status_changed', + statusEvent('failed', { error_message, error_traceback: 'giant traceback', error_type: ' AttributeError ' }) + ); + + await expect(promise).rejects.toMatchObject({ message: 'AttributeError', reason: 'failed' }); + }); + + it('prefers and trims a nonempty backend message over its error type', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + + fake.emit( + 'queue_item_status_changed', + statusEvent('failed', { error_message: ' useful detail ', error_type: 'AttributeError' }) + ); + + await expect(promise).rejects.toMatchObject({ message: 'useful detail', reason: 'failed' }); + }); + + it('uses the generic backend-failed fallback when message and type are missing', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + + fake.emit('queue_item_status_changed', statusEvent('failed')); + + await expect(promise).rejects.toMatchObject({ message: 'The utility graph failed.', reason: 'failed' }); + }); + + it('ignores malformed non-string backend failure fields', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + + fake.emit( + 'queue_item_status_changed', + statusEvent('failed', { error_message: 42, error_type: { name: 'AttributeError' } } as never) + ); + + await expect(promise).rejects.toMatchObject({ message: 'The utility graph failed.', reason: 'failed' }); + }); + + it('rejects canceled on a canceled item', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + fake.emit('queue_item_status_changed', statusEvent('canceled')); + await expect(promise).rejects.toMatchObject({ reason: 'canceled' }); + }); +}); + +describe('runUtilityGraph — timeout + cancellation', () => { + it('rejects with timeout and cancels every accepted backend item', async () => { + vi.useFakeTimers(); + const fake = createFakeHub(); + const cancel = vi.fn((_itemIds: number[]) => Promise.resolve()); + const promise = runUtilityGraph({ + cancel, + createId: () => UTIL_ID, + enqueue: () => Promise.resolve({ enqueued: 3, itemIds: [11, 12, 13] }), + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + timeoutMs: 1000, + }); + const assertion = expect(promise).rejects.toMatchObject({ reason: 'timeout' }); + await vi.advanceTimersByTimeAsync(1000); + await assertion; + expect(cancel).toHaveBeenCalledExactlyOnceWith([11, 12, 13]); + // Listeners cleaned up after timeout. + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + }); + + it('rejects immediately without enqueueing or canceling when the signal is already aborted', async () => { + const fake = createFakeHub(); + const controller = new AbortController(); + const cancel = vi.fn((_itemIds: number[]) => Promise.resolve()); + const enqueue = vi.fn(okEnqueue); + controller.abort(); + const promise = runUtilityGraph({ + cancel, + createId: () => UTIL_ID, + enqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + signal: controller.signal, + }); + await expect(promise).rejects.toMatchObject({ reason: 'aborted' }); + expect(enqueue).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('rejects aborted, cancels accepted backend items, and detaches listeners', async () => { + const fake = createFakeHub(); + const controller = new AbortController(); + const cancel = vi.fn((_itemIds: number[]) => Promise.resolve()); + const promise = runUtilityGraph({ + cancel, + createId: () => UTIL_ID, + enqueue: () => Promise.resolve({ enqueued: 2, itemIds: [21, 22] }), + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + signal: controller.signal, + }); + await Promise.resolve(); + controller.abort(); + await expect(promise).rejects.toMatchObject({ reason: 'aborted' }); + await vi.waitFor(() => expect(cancel).toHaveBeenCalledExactlyOnceWith([21, 22])); + expect(fake.handlerCount('invocation_complete')).toBe(0); + }); + + it('rejects promptly, then cancels accepted items when a pending enqueue resolves', async () => { + const fake = createFakeHub(); + const controller = new AbortController(); + const enqueue = createDeferred<{ enqueued: number; itemIds: number[] }>(); + const cancel = vi.fn((_itemIds: number[]) => Promise.resolve()); + const promise = runUtilityGraph({ + cancel, + createId: () => UTIL_ID, + enqueue: () => enqueue.promise, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + signal: controller.signal, + }); + const assertion = expect(promise).rejects.toMatchObject({ reason: 'aborted' }); + + controller.abort(); + await assertion; + expect(cancel).not.toHaveBeenCalled(); + + enqueue.resolve({ enqueued: 2, itemIds: [31, 32] }); + await vi.waitFor(() => expect(cancel).toHaveBeenCalledExactlyOnceWith([31, 32])); + }); + + it('cancels at most once across abort, timeout, terminal, and cancellation-failure races', async () => { + vi.useFakeTimers(); + const fake = createFakeHub(); + const controller = new AbortController(); + const enqueue = createDeferred<{ enqueued: number; itemIds: number[] }>(); + const cancel = vi.fn((_itemIds: number[]) => Promise.reject(new Error('cancel failed'))); + const promise = runUtilityGraph({ + cancel, + createId: () => UTIL_ID, + enqueue: () => enqueue.promise, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + signal: controller.signal, + timeoutMs: 1000, + }); + const assertion = expect(promise).rejects.toMatchObject({ reason: 'aborted' }); + + controller.abort(); + await vi.advanceTimersByTimeAsync(1000); + fake.emit('queue_item_status_changed', statusEvent('failed')); + enqueue.resolve({ enqueued: 2, itemIds: [41, 42] }); + + await assertion; + await vi.runAllTimersAsync(); + expect(cancel).toHaveBeenCalledExactlyOnceWith([41, 42]); + }); + + it('does not cancel when a terminal success wins before a later abort', async () => { + const fake = createFakeHub(); + const controller = new AbortController(); + const cancel = vi.fn((_itemIds: number[]) => Promise.resolve()); + const promise = runUtilityGraph({ + cancel, + createId: () => UTIL_ID, + enqueue: () => Promise.resolve({ enqueued: 2, itemIds: [51, 52] }), + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + signal: controller.signal, + }); + await Promise.resolve(); + + fake.emit('invocation_complete', completeEvent()); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await expect(promise).resolves.toMatchObject({ imageName: 'filtered.png' }); + controller.abort(); + await Promise.resolve(); + + expect(cancel).not.toHaveBeenCalled(); + }); + + it('does not cancel when an aborted pending enqueue later accepts zero items', async () => { + const fake = createFakeHub(); + const controller = new AbortController(); + const enqueue = createDeferred<{ enqueued: number; itemIds: number[] }>(); + const cancel = vi.fn((_itemIds: number[]) => Promise.resolve()); + const promise = runUtilityGraph({ + cancel, + createId: () => UTIL_ID, + enqueue: () => enqueue.promise, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + signal: controller.signal, + }); + const assertion = expect(promise).rejects.toMatchObject({ reason: 'aborted' }); + + controller.abort(); + enqueue.resolve({ enqueued: 0, itemIds: [] }); + + await assertion; + await Promise.resolve(); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('does not use a timeout when timeoutMs is 0', async () => { + vi.useFakeTimers(); + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + timeoutMs: 0, + }); + await vi.advanceTimersByTimeAsync(DEFAULT_UTILITY_QUEUE_TIMEOUT_MS * 2); + // Still pending — settle it so the promise doesn't leak. + fake.emit('invocation_complete', completeEvent()); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await expect(promise).resolves.toMatchObject({ imageName: 'filtered.png' }); + }); +}); + +describe('runUtilityGraph — enqueue failures', () => { + it.each([ + { enqueued: 1, itemIds: [], label: 'accepted count without ids', canceled: [] }, + { enqueued: 1, itemIds: [Number.NaN], label: 'non-finite id', canceled: [] }, + { enqueued: 1, itemIds: [0], label: 'non-positive id', canceled: [] }, + { enqueued: Number.NaN, itemIds: [11], label: 'non-finite accepted count', canceled: [11] }, + { enqueued: 1.5, itemIds: [11], label: 'fractional accepted count', canceled: [11] }, + { enqueued: -1, itemIds: [11], label: 'negative accepted count', canceled: [11] }, + { enqueued: 2, itemIds: [11, 11], label: 'duplicate ids', canceled: [11] }, + { enqueued: 2, itemIds: [11], label: 'count mismatch', canceled: [11] }, + ])('immediately rejects $label and fully cleans up', async ({ canceled, enqueued, itemIds }) => { + vi.useFakeTimers(); + const fake = createFakeHub(); + const cancel = vi.fn((_itemIds: number[]) => Promise.resolve()); + const promise = runUtilityGraph({ + cancel, + createId: () => UTIL_ID, + enqueue: () => Promise.resolve({ enqueued, itemIds }), + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + + await expect(promise).rejects.toMatchObject({ reason: 'enqueue' }); + await Promise.resolve(); + + expect(cancel).toHaveBeenCalledTimes(canceled.length > 0 ? 1 : 0); + if (canceled.length > 0) { + expect(cancel).toHaveBeenCalledWith(canceled); + } + expect(fake.handlerCount('invocation_complete')).toBe(0); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('accepts a valid multi-item enqueue response', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => Promise.resolve({ enqueued: 2, itemIds: [11, 12] }), + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + await Promise.resolve(); + + fake.emit('invocation_complete', completeEvent()); + fake.emit('queue_item_status_changed', statusEvent('completed')); + + await expect(promise).resolves.toMatchObject({ imageName: 'filtered.png' }); + }); + + it('contains a synchronous enqueue throw and cleans listeners with timeout disabled', async () => { + vi.useFakeTimers(); + const fake = createFakeHub(); + const cause = new Error('synchronous enqueue failure'); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => { + throw cause; + }, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + timeoutMs: 0, + }); + + await expect(promise).rejects.toMatchObject({ cause, message: cause.message, reason: 'enqueue' }); + expect(fake.handlerCount('invocation_complete')).toBe(0); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('contains a synchronous reconciliation scheduler throw and fully settles with timeout disabled', async () => { + vi.useFakeTimers(); + const fake = createFakeHub(); + const cause = new Error('scheduler unavailable'); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + reconcileCompletedOutput: () => Promise.resolve(null), + scheduleReconcileRetry: () => { + throw cause; + }, + timeoutMs: 0, + }); + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + + await expect(promise).rejects.toMatchObject({ + cause, + message: 'Failed to schedule utility graph output reconciliation: scheduler unavailable', + reason: 'reconcile', + }); + expect(fake.handlerCount('invocation_complete')).toBe(0); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('contains a throwing reconciliation retry cancellation seam during socket success', async () => { + const fake = createFakeHub(); + const cancelRetry = vi.fn(() => { + throw new Error('cancel retry failed'); + }); + const scheduleReconcileRetry = vi.fn(() => cancelRetry); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + reconcileCompletedOutput: () => Promise.resolve(null), + scheduleReconcileRetry, + timeoutMs: 0, + }); + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await vi.waitFor(() => expect(scheduleReconcileRetry).toHaveBeenCalledOnce()); + + expect(() => fake.emit('invocation_complete', completeEvent())).not.toThrow(); + await expect(promise).resolves.toMatchObject({ imageName: 'filtered.png' }); + expect(cancelRetry).toHaveBeenCalledOnce(); + expect(fake.handlerCount('invocation_complete')).toBe(0); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + }); + + it('contains a synchronous id-source throw as a setup error', async () => { + const cause = new Error('id source failed'); + const promise = runUtilityGraph({ + createId: () => { + throw cause; + }, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: createFakeHub().hub, + timeoutMs: 0, + }); + + await expect(promise).rejects.toMatchObject({ cause, reason: 'setup' }); + }); + + it('cleans a partially attached listener when the second socket subscription throws', async () => { + const fake = createFakeHub(); + const cause = new Error('subscription failed'); + let calls = 0; + const hub: Pick = { + on: (event, handler) => { + calls += 1; + if (calls === 2) { + throw cause; + } + return fake.hub.on(event, handler); + }, + }; + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub, + timeoutMs: 0, + }); + + await expect(promise).rejects.toMatchObject({ cause, reason: 'setup' }); + expect(fake.handlerCount('invocation_complete')).toBe(0); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + }); + + it('contains a synchronous injected reconciliation classifier throw', async () => { + const fake = createFakeHub(); + const cause = new Error('classifier failed'); + const promise = runUtilityGraph({ + classifyReconcileError: () => { + throw cause; + }, + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + reconcileCompletedOutput: () => Promise.reject(new TypeError('network failed')), + timeoutMs: 0, + }); + await Promise.resolve(); + fake.emit('queue_item_status_changed', statusEvent('completed')); + + await expect(promise).rejects.toMatchObject({ cause, reason: 'reconcile' }); + expect(fake.handlerCount('invocation_complete')).toBe(0); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + }); + + it('cleans listeners when abort-signal subscription throws synchronously', async () => { + const fake = createFakeHub(); + const cause = new Error('abort subscription failed'); + const signal = { + aborted: false, + addEventListener: () => { + throw cause; + }, + removeEventListener: vi.fn(), + } as unknown as AbortSignal; + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + signal, + timeoutMs: 0, + }); + + await expect(promise).rejects.toMatchObject({ cause, reason: 'setup' }); + expect(fake.handlerCount('invocation_complete')).toBe(0); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + }); + + it('rejects enqueue when the backend accepts zero items', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => Promise.resolve({ enqueued: 0, itemIds: [] }), + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + await expect(promise).rejects.toMatchObject({ reason: 'enqueue' }); + }); + + it('rejects enqueue when the enqueue call throws', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => Promise.reject(new Error('network down')), + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + await expect(promise).rejects.toMatchObject({ message: 'network down', reason: 'enqueue' }); + }); +}); + +describe('runUtilityGraph — origin isolation (Risk 4)', () => { + it('mints a webv2:util: origin that project routing provably ignores', () => { + // The utility origin resolves to NO local queue item, so `queueCoordinator` + // (which keys backend items by `parseQueueItemOrigin`) and `routeQueueItemResults` + // never adopt a utility item into project staging / gallery routing. + expect(isUtilityQueueItemOrigin(ORIGIN)).toBe(true); + expect(parseQueueItemOrigin(ORIGIN)).toBeNull(); + + // A real project origin, by contrast, DOES parse to its local queue item id — + // demonstrating the coordinator adopts those but not utility items. + const projectOrigin = buildQueueItemOrigin('local-1', 'project-1'); + expect(isUtilityQueueItemOrigin(projectOrigin)).toBe(false); + expect(parseQueueItemOrigin(projectOrigin)).toBe('local-1'); + }); + + it('ignores socket events whose origin is not this utility run', async () => { + const fake = createFakeHub(); + let settled = false; + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + timeoutMs: 0, + }).then((result) => { + settled = true; + return result; + }); + + // Events for a DIFFERENT origin (another util run or a project item) must not settle us. + const otherOrigin = buildUtilityQueueItemOrigin('other-util'); + fake.emit( + 'invocation_complete', + completeEvent({ origin: otherOrigin, result: { image: { image_name: 'other.png' }, type: 'image_output' } }) + ); + fake.emit( + 'queue_item_status_changed', + statusEvent('completed', { origin: buildQueueItemOrigin('local-1', 'project-1') }) + ); + await Promise.resolve(); + expect(settled).toBe(false); + + // Our origin's events do settle us. + fake.emit('invocation_complete', completeEvent()); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await expect(promise).resolves.toMatchObject({ imageName: 'filtered.png' }); + }); +}); + +describe('UtilityQueueError', () => { + it('carries a reason and a name', () => { + const error = new UtilityQueueError('timeout', 'nope'); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('UtilityQueueError'); + expect(error.reason).toBe('timeout'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/backend/utilityQueue.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/backend/utilityQueue.ts new file mode 100644 index 00000000000..2619d947038 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/backend/utilityQueue.ts @@ -0,0 +1,525 @@ +/** + * The utility queue: fire-and-await small graphs OUTSIDE any project's queue. + * + * Filter previews and (later) Segment-Anything need to run a one-shot graph and + * read back its single output image WITHOUT the result ever touching project + * staging or the gallery. {@link runUtilityGraph} does exactly that: + * + * 1. Mints a fresh `webv2:util:` origin (see `backend/events.ts`). That + * origin is deliberately invisible to project routing — `parseQueueItemOrigin` + * returns `null` for it, so `queueCoordinator.reconcile` / + * `isQueueServerItemInProject` never adopt it and `routeQueueItemResults` + * (only invoked for coordinator-tracked project runs, which a utility item is + * never registered as) never sees it. This is the plan's Risk-4 guard. + * 2. Attaches raw `socketHub.on` listeners (they survive socket recreation) for + * `invocation_complete` (to capture the output image name and dimensions) and + * `queue_item_status_changed` (to settle on the terminal status), matching + * events by our unique origin. + * 3. Enqueues the graph (listeners are attached first, closing the fast-finish + * race), then resolves with the output image metadata on completion, or rejects + * on failure/cancellation/timeout/abort. Abort and timeout also best-effort + * cancel every backend item accepted for this run, including when enqueue + * resolves after the local await has already rejected. + * + * Zero React, zero DOM: `hub` and `enqueue` are injected, so this runs in node + * tests against fakes. Every side-effecting dependency is a parameter. + */ + +import type { InvocationCompleteEvent, QueueItemStatusChangedEvent } from '@workbench/backend/events'; +import type { SocketHub } from '@workbench/backend/socketHub'; +import type { BackendGraphContract } from '@workbench/types'; + +import { buildUtilityQueueItemOrigin } from '@workbench/backend/events'; +import { ApiError } from '@workbench/backend/http'; +import { cancelQueueItems, enqueueUtilityGraph, getQueueItem } from '@workbench/generation/api'; + +/** The default time a utility graph may run before it is abandoned. */ +export const DEFAULT_UTILITY_QUEUE_TIMEOUT_MS = 120_000; + +/** Bounded retries for transient completed-item reconciliation failures. */ +export const DEFAULT_UTILITY_RECONCILE_RETRY_POLICY = { delayMs: 100, maxAttempts: 3 } as const; + +/** Thrown when a utility graph fails, is canceled, times out, or is aborted. */ +export class UtilityQueueError extends Error { + readonly reason: 'failed' | 'canceled' | 'timeout' | 'aborted' | 'no-output' | 'enqueue' | 'reconcile' | 'setup'; + + constructor(reason: UtilityQueueError['reason'], message: string, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }); + this.name = 'UtilityQueueError'; + this.reason = reason; + } +} + +/** The enqueue seam: posts the graph under `origin`, resolving to its backend item ids. */ +export type UtilityEnqueue = (request: { + graph: BackendGraphContract; + origin: string; +}) => Promise<{ itemIds: number[]; enqueued: number }>; + +/** The cancellation seam: best-effort cancels accepted backend item ids. */ +export type UtilityCancel = (itemIds: number[]) => Promise; + +type UtilityImageOutput = { height: number; imageName: string; width: number }; + +const inspectUtilityEnqueueResult = (result: unknown) => { + const response = result as { itemIds?: unknown; enqueued?: unknown } | null; + const rawItemIds = Array.isArray(response?.itemIds) ? response.itemIds : []; + const itemIds = [ + ...new Set( + rawItemIds.filter( + (id): id is number => typeof id === 'number' && Number.isFinite(id) && Number.isInteger(id) && id > 0 + ) + ), + ]; + const enqueued = response?.enqueued; + const valid = + Number.isInteger(enqueued) && + (enqueued as number) > 0 && + itemIds.length === rawItemIds.length && + itemIds.length === enqueued; + return { itemIds, result: valid ? { enqueued: enqueued as number, itemIds } : null }; +}; + +/** Deterministically reads a completed queue item's target output. */ +export type UtilityCompletedOutputReconciler = ( + itemIds: number[], + outputNodeId?: string +) => Promise; + +export interface UtilityReconcileRetryPolicy { + delayMs: number; + maxAttempts: number; +} + +/** Returns a cancellation function for one scheduled reconciliation retry. */ +export type UtilityReconcileRetryScheduler = (callback: () => void, delayMs: number) => () => void; + +export type UtilityReconcileErrorClassifier = (cause: unknown) => 'retry' | 'fail'; + +export const classifyUtilityReconcileError: UtilityReconcileErrorClassifier = (cause) => { + if (cause instanceof TypeError) { + return 'retry'; + } + if (cause instanceof ApiError) { + return cause.status === 408 || cause.status === 429 || (cause.status >= 500 && cause.status <= 599) + ? 'retry' + : 'fail'; + } + return 'fail'; +}; + +const scheduleUtilityReconcileRetry: UtilityReconcileRetryScheduler = (callback, delayMs) => { + const timer = setTimeout(callback, delayMs); + return () => clearTimeout(timer); +}; + +/** Dependencies for {@link runUtilityGraph} (all injectable for tests). */ +export interface RunUtilityGraphOptions { + /** The graph to enqueue and await. */ + graph: BackendGraphContract; + /** + * The source node id whose `invocation_complete` output image is the result. + * When omitted, the first image-bearing completion for our origin is used. + */ + outputNodeId?: string; + /** The socket hub (only `on` is used). Raw listeners survive socket recreation. */ + hub: Pick; + /** Enqueue seam (defaults to the real utility enqueue API). */ + enqueue?: UtilityEnqueue; + /** Cancellation seam (defaults to the real queue cancellation API). */ + cancel?: UtilityCancel; + /** Abandon after this many ms (default {@link DEFAULT_UTILITY_QUEUE_TIMEOUT_MS}). `0` disables. */ + timeoutMs?: number; + /** Cancels the await and best-effort cancels accepted backend items. */ + signal?: AbortSignal; + /** Injectable id source (defaults to `crypto.randomUUID`). */ + createId?: () => string; + /** Injectable completed-item reconciliation seam. */ + reconcileCompletedOutput?: UtilityCompletedOutputReconciler; + /** Bounded reconciliation retry policy. */ + reconcileRetryPolicy?: UtilityReconcileRetryPolicy; + /** Injectable reconciliation retry timer seam. */ + scheduleReconcileRetry?: UtilityReconcileRetryScheduler; + /** Injectable reconciliation failure classifier. */ + classifyReconcileError?: UtilityReconcileErrorClassifier; +} + +/** The resolved result of a utility graph: its single output image. */ +export interface UtilityGraphResult { + height: number; + imageName: string; + /** The origin used, for diagnostics/tests. */ + origin: string; + width: number; +} + +/** Extracts the current backend ImageOutput fields from a completion payload. */ +const extractImageOutput = (result: InvocationCompleteEvent['result'] | undefined): UtilityImageOutput | null => { + const output = result as { height?: unknown; image?: { image_name?: unknown }; width?: unknown } | undefined; + if ( + typeof output?.image?.image_name !== 'string' || + typeof output.width !== 'number' || + !Number.isFinite(output.width) || + !Number.isInteger(output.width) || + output.width <= 0 || + typeof output.height !== 'number' || + !Number.isFinite(output.height) || + !Number.isInteger(output.height) || + output.height <= 0 + ) { + return null; + } + return { height: output.height, imageName: output.image.image_name, width: output.width }; +}; + +export const reconcileUtilityCompletedOutput: UtilityCompletedOutputReconciler = async (itemIds, outputNodeId) => { + for (const itemId of itemIds) { + const item = await getQueueItem(itemId); + if ( + !item || + typeof item !== 'object' || + item.item_id !== itemId || + !item.session || + typeof item.session !== 'object' || + !item.session.results || + typeof item.session.results !== 'object' || + Array.isArray(item.session.results) || + (item.session.prepared_source_mapping !== undefined && + (typeof item.session.prepared_source_mapping !== 'object' || + item.session.prepared_source_mapping === null || + Array.isArray(item.session.prepared_source_mapping))) + ) { + throw new Error(`Queue item ${itemId} returned malformed reconciliation data.`); + } + const results = item.session?.results ?? {}; + const preparedSourceMapping = item.session?.prepared_source_mapping ?? {}; + for (const [preparedNodeId, result] of Object.entries(results)) { + if (outputNodeId && (preparedSourceMapping[preparedNodeId] ?? preparedNodeId) !== outputNodeId) { + continue; + } + const output = extractImageOutput(result as InvocationCompleteEvent['result']); + if (output) { + return output; + } + if (outputNodeId) { + throw new Error(`Queue item ${itemId} returned malformed output for node ${outputNodeId}.`); + } + } + } + return null; +}; + +/** + * Runs `graph` on the utility queue and resolves with its output image metadata. + * Never routes into project state (isolated origin). Rejects with a + * {@link UtilityQueueError} on failure/cancel/timeout/abort/enqueue error. + */ +export const runUtilityGraph = (options: RunUtilityGraphOptions): Promise => { + const { graph, hub, outputNodeId, signal } = options; + const enqueue = options.enqueue ?? enqueueUtilityGraph; + const cancel = options.cancel ?? cancelQueueItems; + const timeoutMs = options.timeoutMs ?? DEFAULT_UTILITY_QUEUE_TIMEOUT_MS; + const reconcileCompletedOutput = options.reconcileCompletedOutput ?? reconcileUtilityCompletedOutput; + const reconcileRetryPolicy = options.reconcileRetryPolicy ?? DEFAULT_UTILITY_RECONCILE_RETRY_POLICY; + const scheduleReconcileRetry = options.scheduleReconcileRetry ?? scheduleUtilityReconcileRetry; + const classifyReconcileError = options.classifyReconcileError ?? classifyUtilityReconcileError; + const reconcileMaxAttempts = Number.isFinite(reconcileRetryPolicy.maxAttempts) + ? Math.max(1, Math.floor(reconcileRetryPolicy.maxAttempts)) + : DEFAULT_UTILITY_RECONCILE_RETRY_POLICY.maxAttempts; + const reconcileDelayMs = Number.isFinite(reconcileRetryPolicy.delayMs) + ? Math.max(0, reconcileRetryPolicy.delayMs) + : DEFAULT_UTILITY_RECONCILE_RETRY_POLICY.delayMs; + let origin: string; + try { + const utilityId = (options.createId ?? (() => crypto.randomUUID()))(); + origin = buildUtilityQueueItemOrigin(utilityId); + } catch (cause) { + const detail = cause instanceof Error ? cause.message : String(cause); + return Promise.reject(new UtilityQueueError('setup', `Failed to initialize utility graph run: ${detail}`, cause)); + } + + return new Promise((resolve, reject) => { + let settled = false; + let capturedOutput: { height: number; imageName: string; width: number } | null = null; + let cancellationRequested = false; + let cancellationStarted = false; + let enqueueResult: { itemIds: number[]; enqueued: number } | null = null; + let completionReceived = false; + let reconciliationAttempts = 0; + let reconciliationInFlight = false; + let cancelReconciliationRetry: (() => void) | null = null; + const detachers: Array<() => void> = []; + let timer: ReturnType | null = null; + + const cancelAcceptedItems = (): void => { + if ( + !cancellationRequested || + cancellationStarted || + enqueueResult === null || + enqueueResult.enqueued === 0 || + enqueueResult.itemIds.length === 0 + ) { + return; + } + + cancellationStarted = true; + const itemIds = [...enqueueResult.itemIds]; + void Promise.resolve() + .then(() => cancel(itemIds)) + .catch(() => { + // Cancellation is best-effort and must never mask the original settle reason. + }); + }; + + const requestBackendCancellation = (): void => { + cancellationRequested = true; + cancelAcceptedItems(); + }; + + const cleanup = (): void => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + const cancelRetry = cancelReconciliationRetry; + cancelReconciliationRetry = null; + if (cancelRetry) { + try { + cancelRetry(); + } catch { + // Cleanup is best-effort and must not interrupt settlement. + } + } + for (const detach of detachers) { + try { + detach(); + } catch { + // Continue detaching the remaining listeners. + } + } + detachers.length = 0; + if (signal) { + try { + signal.removeEventListener('abort', onAbort); + } catch { + // Cleanup must not interrupt settlement. + } + } + }; + + const settleResolve = (output: { height: number; imageName: string; width: number }): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve({ ...output, origin }); + }; + + const settleReject = (error: UtilityQueueError): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + }; + + const reconcileCompletion = (): void => { + if ( + settled || + !completionReceived || + reconciliationInFlight || + cancelReconciliationRetry !== null || + enqueueResult === null + ) { + return; + } + if (capturedOutput) { + settleResolve(capturedOutput); + return; + } + reconciliationAttempts += 1; + reconciliationInFlight = true; + const reconciliationItemIds = [...enqueueResult.itemIds]; + const scheduleNextReconciliation = (): void => { + try { + const cancelRetry = scheduleReconcileRetry(() => { + cancelReconciliationRetry = null; + reconcileCompletion(); + }, reconcileDelayMs); + cancelReconciliationRetry = typeof cancelRetry === 'function' ? cancelRetry : null; + } catch (cause) { + const detail = cause instanceof Error ? cause.message : String(cause); + settleReject( + new UtilityQueueError( + 'reconcile', + `Failed to schedule utility graph output reconciliation: ${detail}`, + cause + ) + ); + } + }; + void Promise.resolve() + .then(() => reconcileCompletedOutput(reconciliationItemIds, outputNodeId)) + .then((output) => { + reconciliationInFlight = false; + if (settled) { + return; + } + if (capturedOutput) { + settleResolve(capturedOutput); + } else if (output) { + settleResolve(output); + } else if (reconciliationAttempts >= reconcileMaxAttempts) { + settleReject(new UtilityQueueError('no-output', 'The utility graph produced no output image.')); + } else { + scheduleNextReconciliation(); + } + }) + .catch((cause: unknown) => { + reconciliationInFlight = false; + if (settled || capturedOutput) { + if (capturedOutput) { + settleResolve(capturedOutput); + } + return; + } + let classification: ReturnType; + try { + classification = classifyReconcileError(cause); + } catch (classifierCause) { + const detail = classifierCause instanceof Error ? classifierCause.message : String(classifierCause); + settleReject( + new UtilityQueueError( + 'reconcile', + `Failed to classify utility reconciliation error: ${detail}`, + classifierCause + ) + ); + return; + } + if (classification === 'fail' || reconciliationAttempts >= reconcileMaxAttempts) { + const detail = cause instanceof Error ? cause.message : String(cause); + settleReject( + new UtilityQueueError( + 'reconcile', + `Failed to reconcile utility graph output after ${reconciliationAttempts} attempts: ${detail}`, + cause + ) + ); + return; + } + scheduleNextReconciliation(); + }); + }; + + function onAbort(): void { + if (settled) { + return; + } + requestBackendCancellation(); + settleReject(new UtilityQueueError('aborted', 'The utility graph was aborted.')); + } + + // Attach listeners BEFORE enqueue so a very fast completion is not missed. + try { + detachers.push( + hub.on('invocation_complete', (event: InvocationCompleteEvent) => { + if (event.origin !== origin) { + return; + } + // Only take the target node's output (or, when unspecified, any image). + if (outputNodeId && event.invocation_source_id !== outputNodeId) { + return; + } + const output = extractImageOutput(event.result); + if (output) { + capturedOutput = output; + if (completionReceived) { + settleResolve(output); + } + } + }) + ); + + detachers.push( + hub.on('queue_item_status_changed', (event: QueueItemStatusChangedEvent) => { + if (event.origin !== origin) { + return; + } + if (event.status === 'completed') { + completionReceived = true; + reconcileCompletion(); + } else if (event.status === 'failed') { + const errorMessage = typeof event.error_message === 'string' ? event.error_message.trim() : ''; + const errorType = typeof event.error_type === 'string' ? event.error_type.trim() : ''; + settleReject(new UtilityQueueError('failed', errorMessage || errorType || 'The utility graph failed.')); + } else if (event.status === 'canceled') { + settleReject(new UtilityQueueError('canceled', 'The utility graph was canceled.')); + } + }) + ); + } catch (cause) { + const detail = cause instanceof Error ? cause.message : String(cause); + settleReject(new UtilityQueueError('setup', `Failed to attach utility graph listeners: ${detail}`, cause)); + return; + } + + try { + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort); + } + } catch (cause) { + const detail = cause instanceof Error ? cause.message : String(cause); + settleReject(new UtilityQueueError('setup', `Failed to attach utility graph abort listener: ${detail}`, cause)); + return; + } + + if (timeoutMs > 0) { + timer = setTimeout(() => { + if (settled) { + return; + } + requestBackendCancellation(); + settleReject(new UtilityQueueError('timeout', `The utility graph timed out after ${timeoutMs}ms.`)); + }, timeoutMs); + } + + // Enqueue last. A synchronous listener callback could already have fired by + // the time this resolves; `settled` guards against a late enqueue result. + void Promise.resolve() + .then(() => enqueue({ graph, origin })) + .then((result) => { + const inspected = inspectUtilityEnqueueResult(result); + const validResult = inspected.result; + if (!validResult) { + const knownItemIds = inspected.itemIds; + enqueueResult = { enqueued: knownItemIds.length, itemIds: knownItemIds }; + requestBackendCancellation(); + settleReject( + new UtilityQueueError('enqueue', 'The backend returned an inconsistent utility enqueue response.') + ); + return; + } + enqueueResult = validResult; + cancelAcceptedItems(); + if (!settled) { + reconcileCompletion(); + } + }) + .catch((error: unknown) => { + settleReject( + new UtilityQueueError( + 'enqueue', + error instanceof Error ? error.message : 'Failed to enqueue utility graph.', + error + ) + ); + }); + }); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/canvasImportNotice.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/canvasImportNotice.test.ts new file mode 100644 index 00000000000..e7951b20c3c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/canvasImportNotice.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { getCanvasImportNotice } from './canvasImportNotice'; + +describe('getCanvasImportNotice', () => { + it('maps a complete import to a localized success notice', () => { + expect( + getCanvasImportNotice({ failedImageNames: [], layerIds: ['layer-1', 'layer-2'], status: 'imported' }) + ).toEqual({ + kind: 'success', + options: { count: 2 }, + titleKey: 'widgets.canvas.import.success', + }); + }); + + it('maps one successful partial import with the exact pluralization interpolation', () => { + expect( + getCanvasImportNotice({ + failedImageNames: ['failed-1.png', 'failed-2.png'], + layerIds: ['layer-1'], + status: 'imported', + }) + ).toEqual({ + kind: 'info', + options: { count: 1, failedCount: 2, successCount: 1 }, + titleKey: 'widgets.canvas.import.partial', + }); + }); + + it('maps multiple successful partial imports with the exact pluralization interpolation', () => { + expect( + getCanvasImportNotice({ + failedImageNames: ['failed.png'], + layerIds: ['layer-1', 'layer-2'], + status: 'imported', + }) + ).toEqual({ + kind: 'info', + options: { count: 2, failedCount: 1, successCount: 2 }, + titleKey: 'widgets.canvas.import.partial', + }); + }); + + it.each([ + ['blocked', 'info', 'widgets.canvas.import.blocked'], + ['empty', 'info', 'widgets.canvas.import.empty'], + ['stale-document', 'error', 'widgets.canvas.import.staleDocument'], + ['stale-project', 'error', 'widgets.canvas.import.staleProject'], + ] as const)('maps %s to the expected localized notice', (status, kind, titleKey) => { + expect(getCanvasImportNotice({ status })).toEqual({ kind, titleKey }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/canvasImportNotice.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/canvasImportNotice.ts new file mode 100644 index 00000000000..81b7cff3726 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/canvasImportNotice.ts @@ -0,0 +1,49 @@ +import type { ImportGalleryImagesResult } from './importGalleryImages'; + +export interface CanvasImportNotice { + kind: 'error' | 'info' | 'success'; + titleKey: + | 'widgets.canvas.import.blocked' + | 'widgets.canvas.import.empty' + | 'widgets.canvas.import.partial' + | 'widgets.canvas.import.staleDocument' + | 'widgets.canvas.import.staleProject' + | 'widgets.canvas.import.success'; + options?: { count: number } | { count: number; failedCount: number; successCount: number }; +} + +const assertNever = (value: never): never => { + throw new Error(`Unhandled canvas import result: ${JSON.stringify(value)}`); +}; + +export const getCanvasImportNotice = (result: ImportGalleryImagesResult): CanvasImportNotice => { + switch (result.status) { + case 'imported': + if (result.failedImageNames.length > 0) { + return { + kind: 'info', + options: { + count: result.layerIds.length, + failedCount: result.failedImageNames.length, + successCount: result.layerIds.length, + }, + titleKey: 'widgets.canvas.import.partial', + }; + } + return { + kind: 'success', + options: { count: result.layerIds.length }, + titleKey: 'widgets.canvas.import.success', + }; + case 'blocked': + return { kind: 'info', titleKey: 'widgets.canvas.import.blocked' }; + case 'empty': + return { kind: 'info', titleKey: 'widgets.canvas.import.empty' }; + case 'stale-document': + return { kind: 'error', titleKey: 'widgets.canvas.import.staleDocument' }; + case 'stale-project': + return { kind: 'error', titleKey: 'widgets.canvas.import.staleProject' }; + default: + return assertNever(result); + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/compositeForGeneration.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/compositeForGeneration.test.ts new file mode 100644 index 00000000000..6a87283a5c9 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/compositeForGeneration.test.ts @@ -0,0 +1,403 @@ +import type { CanvasImageUploadResult } from '@workbench/canvas-engine/document/imageUpload'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { Rect } from '@workbench/generation/canvas/types'; +import type { + CanvasDocumentContractV2, + CanvasImageRef, + CanvasLayerContract, + CanvasRasterLayerContractV2, +} from '@workbench/types'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { planComposites } from '@workbench/generation/canvas/compositePlan'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ExecuteCompositePlanDeps } from './compositeForGeneration'; + +import { + createCompositeDedupeCache, + executeCompositePlan, + executeMaskComposite, + toGrayscaleMaskPixels, +} from './compositeForGeneration'; + +const imageRef = (imageName: string, width = 64, height = 48): CanvasImageRef => ({ height, imageName, width }); + +const rasterLayer = ( + id: string, + overrides: Partial = {} +): CanvasRasterLayerContractV2 => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: imageRef(id), type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...overrides, +}); + +const makeDoc = (layers: CanvasLayerContract[]): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId: null, + version: 2, + width: 100, +}); + +const BBOX: Rect = { height: 100, width: 100, x: 0, y: 0 }; + +/** Builds an ImageData-like object with a uniform alpha (255 = opaque). */ +const uniformImageData = (width: number, height: number, alpha: number): ImageData => { + const data = new Uint8ClampedArray(width * height * 4); + for (let i = 3; i < data.length; i += 4) { + data[i] = alpha; + } + return { colorSpace: 'srgb', data, height, width } as unknown as ImageData; +}; + +/** An opaque scan except a single transparent pixel (a hole). */ +const holeImageData = (width: number, height: number): ImageData => { + const image = uniformImageData(width, height, 255); + image.data[3] = 0; + return image; +}; + +interface Harness { + deps: ExecuteCompositePlanDeps; + createdTargets: StubRasterSurface[]; + layerSurfaces: Map; + uploadImage: ReturnType; + encodeSurface: ReturnType; +} + +const makeHarness = (readImageData?: (surface: RasterSurface, rect: Rect) => ImageData): Harness => { + const stub = createTestStubRasterBackend(); + const createdTargets: StubRasterSurface[] = []; + const layerSurfaces = new Map(); + + const encodeSurface = vi.fn((surface: RasterSurface) => stub.encodeSurface(surface)); + const backend = { + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = stub.createSurface(w, h); + createdTargets.push(surface); + return surface; + }, + encodeSurface, + }; + + const getLayerSurface = (layerId: string): Promise<{ surface: RasterSurface; rect: Rect }> => { + let surface = layerSurfaces.get(layerId); + if (!surface) { + // Layer caches are content-sized; created off the raw stub so they don't + // pollute the composite-target capture. Origin-anchored (0,0) here. + surface = stub.createSurface(64, 48); + layerSurfaces.set(layerId, surface); + } + return Promise.resolve({ rect: { height: 48, width: 64, x: 0, y: 0 }, surface }); + }; + + let counter = 0; + const uploadImage = vi.fn((blob: Blob): Promise => { + void blob; + counter += 1; + return Promise.resolve({ height: 100, imageName: `uploaded-${counter}`, width: 100 }); + }); + + const deps: ExecuteCompositePlanDeps = { + backend, + dedupe: createCompositeDedupeCache(), + getLayerSurface, + // Content-addressed hash: identical blob bytes → identical hash. + hashBlob: (blob: Blob) => blob.text(), + readImageData, + uploadImage, + }; + + return { createdTargets, deps, encodeSurface, layerSurfaces, uploadImage }; +}; + +describe('executeCompositePlan — compositing', () => { + it('composites enabled raster layers bottom→top into a bbox-sized surface', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 255)); + const doc = makeDoc([rasterLayer('top', { opacity: 0.4 }), rasterLayer('bottom', { opacity: 0.9 })]); + const plan = planComposites(doc, BBOX); + + await executeCompositePlan(plan, harness.deps); + + expect(harness.createdTargets).toHaveLength(1); + const target = harness.createdTargets[0]!; + expect(target.width).toBe(100); + expect(target.height).toBe(100); + + const drawImages = target.callLog.filter((e) => e.op === 'drawImage'); + expect(drawImages).toHaveLength(2); + // Bottom layer (array index 1) is drawn first. + expect(drawImages[0]!.args[0]).toBe(harness.layerSurfaces.get('bottom')!.canvas); + expect(drawImages[1]!.args[0]).toBe(harness.layerSurfaces.get('top')!.canvas); + + // Per-layer opacity is applied. + const alphas = target.callLog.filter((e) => e.op === 'set' && e.args[0] === 'globalAlpha').map((e) => e.args[1]); + expect(alphas).toContain(0.4); + expect(alphas).toContain(0.9); + }); + + it('translates the composite by the bbox origin (crops to the bbox)', async () => { + const harness = makeHarness(() => uniformImageData(50, 50, 255)); + const doc = makeDoc([rasterLayer('a')]); + const bbox: Rect = { height: 50, width: 50, x: 20, y: 10 }; + const plan = planComposites(doc, bbox); + + await executeCompositePlan(plan, harness.deps); + + const target = harness.createdTargets[0]!; + // The layer sits at document origin; under the bbox translate it draws at (-20, -10). + const setTransforms = target.callLog.filter((e) => e.op === 'setTransform'); + expect(setTransforms.some((e) => e.args[4] === -20 && e.args[5] === -10)).toBe(true); + }); +}); + +describe('executeCompositePlan — mode geometry', () => { + it('reports bboxFullyCovered=true for an opaque composite', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 255)); + const plan = planComposites(makeDoc([rasterLayer('a')]), BBOX); + const result = await executeCompositePlan(plan, harness.deps); + expect(result.bboxFullyCovered).toBe(true); + }); + + it('reports bboxFullyCovered=false when the composite has a transparent hole', async () => { + const harness = makeHarness(() => holeImageData(100, 100)); + const plan = planComposites(makeDoc([rasterLayer('a')]), BBOX); + const result = await executeCompositePlan(plan, harness.deps); + expect(result.bboxFullyCovered).toBe(false); + }); + + it('computes contentBounds as the union of layer bounds in document space', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 255)); + const doc = makeDoc([ + rasterLayer('a'), // 64x48 at (0,0) + rasterLayer('b', { transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 100, y: 100 } }), // 64x48 at (100,100) + ]); + const plan = planComposites(doc, BBOX); + const result = await executeCompositePlan(plan, harness.deps); + expect(result.contentBounds).toEqual({ height: 148, width: 164, x: 0, y: 0 }); + }); + + it('returns contentBounds=null when there is no enabled raster content', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 0)); + const plan = planComposites(makeDoc([rasterLayer('a', { isEnabled: false })]), BBOX); + const result = await executeCompositePlan(plan, harness.deps); + expect(result.contentBounds).toBeNull(); + }); +}); + +describe('executeCompositePlan — upload + dedupe', () => { + it('reserves the composite surface and coverage buffer until execution settles', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 255)); + const release = vi.fn(); + const reserve = vi.fn(() => ({ lease: { release }, status: 'ok' as const })); + harness.deps.reserve = reserve; + const plan = planComposites(makeDoc([rasterLayer('a')]), BBOX); + + await executeCompositePlan(plan, harness.deps); + + expect(reserve).toHaveBeenCalledWith(80_000); + expect(release).toHaveBeenCalledOnce(); + }); + + it('refuses a composite before allocating when its reservation is over budget', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 255)); + harness.deps.reserve = () => ({ availableBytes: 0, requestedBytes: 80_000, status: 'over-budget' }); + const plan = planComposites(makeDoc([rasterLayer('a')]), BBOX); + + await expect(executeCompositePlan(plan, harness.deps)).rejects.toThrow( + 'The canvas composite exceeds the available raster memory budget' + ); + expect(harness.createdTargets).toHaveLength(0); + }); + + it('encodes and uploads once, returning the uploaded image name', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 255)); + const plan = planComposites(makeDoc([rasterLayer('a')]), BBOX); + + const result = await executeCompositePlan(plan, harness.deps); + + expect(harness.encodeSurface).toHaveBeenCalledTimes(1); + expect(harness.uploadImage).toHaveBeenCalledTimes(1); + expect(result.base.imageName).toBe('uploaded-1'); + expect(result.base.reusedUpload).toBe(false); + }); + + it('re-running the same plan skips compositing, encoding and upload entirely', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 255)); + const plan = planComposites(makeDoc([rasterLayer('a')]), BBOX); + + await executeCompositePlan(plan, harness.deps); + harness.createdTargets.length = 0; + const second = await executeCompositePlan(plan, harness.deps); + + // No new composite target, no new encode, no new upload. + expect(harness.createdTargets).toHaveLength(0); + expect(harness.encodeSurface).toHaveBeenCalledTimes(1); + expect(harness.uploadImage).toHaveBeenCalledTimes(1); + expect(second.base.reusedUpload).toBe(true); + expect(second.base.imageName).toBe('uploaded-1'); + }); + + it('a changed plan with identical pixels re-composites but reuses the upload by content hash', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 255)); + const planA = planComposites(makeDoc([rasterLayer('a', { opacity: 1 })]), BBOX); + const planB = planComposites(makeDoc([rasterLayer('a', { opacity: 0.5 })]), BBOX); + expect(planA.entries[0]!.key).not.toBe(planB.entries[0]!.key); + + await executeCompositePlan(planA, harness.deps); + const resultB = await executeCompositePlan(planB, harness.deps); + + // The changed plan re-composites + re-encodes (same-size stub blob → same hash)... + expect(harness.encodeSurface).toHaveBeenCalledTimes(2); + // ...but the identical pixel hash means no second upload. + expect(harness.uploadImage).toHaveBeenCalledTimes(1); + expect(resultB.base.reusedUpload).toBe(true); + expect(resultB.base.imageName).toBe('uploaded-1'); + }); +}); + +// ---- Grayscale mask composite --------------------------------------------- + +const inpaintMask = ( + id: string, + overrides: Partial<{ denoiseLimit: number; noiseLevel: number }> = {} +): CanvasLayerContract => ({ + blendMode: 'normal', + denoiseLimit: overrides.denoiseLimit, + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: imageRef(`${id}-bmp`, 64, 48), fill: { color: '#ff0000', style: 'solid' } }, + name: id, + noiseLevel: overrides.noiseLevel, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +const maskEntryOf = (doc: CanvasDocumentContractV2) => + planComposites(doc, BBOX).entries.find((e) => e.kind === 'inpaint-mask')!; + +/** A grayscale ImageData that is uniformly white or has a single dark pixel. */ +const grayImageData = (width: number, height: number, hasDark: boolean): ImageData => { + const data = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < data.length; i += 4) { + data[i] = 255; + data[i + 1] = 255; + data[i + 2] = 255; + data[i + 3] = 255; + } + if (hasDark) { + data[0] = 0; + } + return { colorSpace: 'srgb', data, height, width } as unknown as ImageData; +}; + +describe('toGrayscaleMaskPixels', () => { + const pixel = (alpha: number): ImageData => { + const data = new Uint8ClampedArray([120, 130, 140, alpha]); + return { colorSpace: 'srgb', data, height: 1, width: 1 } as unknown as ImageData; + }; + + it('turns a masked pixel dark by the attribute value (1.0 → black)', () => { + const img = pixel(255); + toGrayscaleMaskPixels(img, 1); + expect(Array.from(img.data)).toEqual([0, 0, 0, 255]); + }); + + it('turns a masked pixel mid-gray at partial strength', () => { + const img = pixel(255); + toGrayscaleMaskPixels(img, 0.5); + // 255 - round(255 * 0.5) = 255 - 128 = 127 + expect(Array.from(img.data)).toEqual([127, 127, 127, 255]); + }); + + it('leaves an unmasked (transparent) pixel white', () => { + const img = pixel(0); + toGrayscaleMaskPixels(img, 1); + expect(Array.from(img.data)).toEqual([255, 255, 255, 255]); + }); +}); + +describe('executeMaskComposite', () => { + it('composites mask layers onto a white bbox surface and uploads the result', async () => { + const harness = makeHarness(() => grayImageData(100, 100, true)); + const entry = maskEntryOf(makeDoc([inpaintMask('m1')])); + + const result = await executeMaskComposite(entry, harness.deps); + + // White background fill + one mask draw. + const target = harness.createdTargets[0]!; + const fills = target.callLog.filter((e) => e.op === 'set' && e.args[0] === 'fillStyle'); + expect(fills.map((e) => e.args[1])).toContain('white'); + expect(harness.uploadImage).toHaveBeenCalledTimes(1); + expect(result.imageName).toBe('uploaded-1'); + expect(result.hasContent).toBe(true); + }); + + it('reports no content when the composite is fully white', async () => { + const harness = makeHarness(() => grayImageData(100, 100, false)); + const entry = maskEntryOf(makeDoc([inpaintMask('m1')])); + const result = await executeMaskComposite(entry, harness.deps); + expect(result.hasContent).toBe(false); + }); + + it('dedupes a repeated mask plan key with no second upload', async () => { + const harness = makeHarness(() => grayImageData(100, 100, true)); + const doc = makeDoc([inpaintMask('m1', { denoiseLimit: 0.5 })]); + + const first = await executeMaskComposite(maskEntryOf(doc), harness.deps); + const second = await executeMaskComposite(maskEntryOf(doc), harness.deps); + + expect(harness.uploadImage).toHaveBeenCalledTimes(1); + expect(second.reusedUpload).toBe(true); + expect(second.imageName).toBe(first.imageName); + expect(second.hasContent).toBe(true); + }); + + it('darken-composites multiple mask layers', async () => { + const harness = makeHarness(() => grayImageData(100, 100, true)); + const entry = maskEntryOf(makeDoc([inpaintMask('a'), inpaintMask('b')])); + + await executeMaskComposite(entry, harness.deps); + + const target = harness.createdTargets[0]!; + const composites = target.callLog + .filter((e) => e.op === 'set' && e.args[0] === 'globalCompositeOperation') + .map((e) => e.args[1]); + expect(composites).toContain('darken'); + }); +}); + +describe('executeCompositePlan — raster adjustments baked into generation pixels', () => { + it('reads + writes the adjusted layer pixels (bake) and NOT for a plain layer', async () => { + const writeImageData = vi.fn(); + const readImageData = vi.fn((_surface: RasterSurface, rect: Rect) => + uniformImageData(rect.width, rect.height, 255) + ); + const harness = makeHarness(readImageData); + harness.deps.writeImageData = writeImageData; + + // Plain layer: no adjustments → no bake write beyond the coverage scan writes (none here). + const plainDoc = makeDoc([rasterLayer('a')]); + await executeCompositePlan(planComposites(plainDoc, BBOX), harness.deps); + expect(writeImageData).not.toHaveBeenCalled(); + + // Adjusted layer: brightness bake → the executor reads the temp, applies the + // LUT, and writes the adjusted pixels back before compositing. + const adjustedDoc = makeDoc([rasterLayer('b', { adjustments: { brightness: 0.5, contrast: 0, saturation: 0 } })]); + await executeCompositePlan(planComposites(adjustedDoc, BBOX), harness.deps); + expect(writeImageData).toHaveBeenCalledTimes(1); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/compositeForGeneration.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/compositeForGeneration.ts new file mode 100644 index 00000000000..ac31136540c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/compositeForGeneration.ts @@ -0,0 +1,491 @@ +/** + * The composite-plan executor: turns a {@link CompositePlan} into an uploaded, + * bbox-sized base image plus the geometry the mode detector needs. + * + * This is the impure half of the canvas → generation pipeline (the planner in + * `generation/canvas/compositePlan.ts` is pure). It lives under `canvas-engine` + * and has zero React: every side-effecting dependency (surface allocation, + * layer rasterization, encode, hash, upload) is injected, so it runs in node + * tests against `render/raster.testStub.ts` + a mock uploader — no fetch, no DOM. + * + * For each `base-raster` entry it: + * 1. Composites the entry's enabled raster layers, in z-order, through each + * layer's transform / opacity / blend mode, cropped to the bbox onto a + * bbox-sized surface (following `render/compositor.ts`'s draw model, where + * the "view" is a bbox translate). Layers are rasterized on demand via the + * injected {@link ExecuteCompositePlanDeps.getLayerSurface}. + * 2. Computes `contentBounds` (union of the entry's layer bounds in document + * space) and `bboxFullyCovered` (an alpha scan of the composited surface). + * 3. Encodes → PNG blob → SHA-256, then dedupes: an unchanged plan key reuses + * the previous upload with zero new work, and a changed plan whose pixels + * hash identically reuses the previous upload via the content hash. + * + * Dedupe state lives in a caller-owned {@link CompositeDedupeCache} passed + * through `deps`, so it persists across invokes while the function stays a plain + * `(plan, deps) → result`. + */ + +import type { CanvasImageUploadResult } from '@workbench/canvas-engine/document/imageUpload'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Mat2d, Rect } from '@workbench/canvas-engine/types'; +import type { CompositeEntry, CompositeMaskLayerRef, CompositePlan } from '@workbench/generation/canvas/types'; + +import { fromTRS, multiply } from '@workbench/canvas-engine/math/mat2d'; +import { renderRasterComposite } from '@workbench/canvas-engine/render/rasterComposite'; +import { getCompositeLayerBounds } from '@workbench/generation/canvas/compositePlan'; + +type Ctx = RasterSurface['ctx']; + +/** SHA-256 hex of a blob's bytes, via the Web Crypto API (matches `bitmapStore`). */ +const defaultHashBlob = async (blob: Blob): Promise => { + const buffer = await blob.arrayBuffer(); + const digest = await crypto.subtle.digest('SHA-256', buffer); + const bytes = new Uint8Array(digest); + let hex = ''; + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, '0'); + } + return hex; +}; + +/** Reads a surface's pixels via its 2D context (real DOM path; injectable for tests). */ +const defaultReadImageData = (surface: RasterSurface, rect: Rect): ImageData => + surface.ctx.getImageData(rect.x, rect.y, rect.width, rect.height); + +/** Writes pixels back to a surface's 2D context (real DOM path; injectable for tests). */ +const defaultWriteImageData = (surface: RasterSurface, imageData: ImageData, x: number, y: number): void => + surface.ctx.putImageData(imageData, x, y); + +/** + * A caller-owned dedupe cache, persisted across executor calls: + * - `byKey`: plan key → its last result, so an unchanged plan skips all work. + * - `byHash`: pixel hash → uploaded image, so a changed plan with identical + * pixels reuses the upload. + */ +export interface CompositeDedupeCache { + byKey: Map; + byHash: Map; +} + +/** A cached executor result for one plan key. */ +export interface CompositeCacheEntry { + imageName: string; + width: number; + height: number; + pixelHash: string; + bboxFullyCovered: boolean; +} + +/** Creates an empty {@link CompositeDedupeCache}. */ +export const createCompositeDedupeCache = (): CompositeDedupeCache => ({ + byHash: new Map(), + byKey: new Map(), +}); + +/** Injected dependencies for {@link executeCompositePlan}. */ +export interface ExecuteCompositePlanDeps { + /** Surface factory + encoder seam (usually the engine's `RasterBackend`). */ + backend: { + createSurface(width: number, height: number): RasterSurface; + encodeSurface(surface: RasterSurface, type?: string): Promise; + }; + /** + * Ensures a layer's cache is rasterized and returns its surface plus the + * content `rect` (layer-local origin/size) those pixels occupy. The executor + * draws the surface at `rect.origin` (then through the layer transform). The + * engine wires this to its rasterize path; tests return a stub. + */ + getLayerSurface(layerId: string): Promise<{ surface: RasterSurface; rect: Rect }>; + /** + * Uploads a composited blob and resolves to its server image name + dims. The + * engine wires this to `uploadCanvasImage(blob, { isIntermediate: true })`. + */ + uploadImage(blob: Blob): Promise; + /** Persistent dedupe state (see {@link CompositeDedupeCache}). */ + dedupe: CompositeDedupeCache; + /** Content-hashes a blob (default SHA-256 hex via `crypto.subtle`). */ + hashBlob?(blob: Blob): Promise; + /** Reads a surface region's pixels for the coverage scan (default `getImageData`). */ + readImageData?(surface: RasterSurface, rect: Rect): ImageData; + /** Reserves transient raster bytes for the complete composite operation. */ + reserve?( + bytes: number + ): + | { status: 'ok'; lease: { release(): void } } + | { status: 'over-budget'; requestedBytes: number; availableBytes: number }; + /** Writes pixels back to a surface (default `putImageData`; injectable for tests). */ + writeImageData?(surface: RasterSurface, imageData: ImageData, x: number, y: number): void; +} + +export class CompositeOverBudgetError extends Error { + constructor() { + super('The canvas composite exceeds the available raster memory budget.'); + this.name = 'CompositeOverBudgetError'; + } +} + +const reserveComposite = ( + entry: CompositeEntry, + deps: ExecuteCompositePlanDeps, + layerCount: number +): { release(): void } => { + const pixels = Math.max(0, entry.bbox.width) * Math.max(0, entry.bbox.height); + // Final/accumulator surface + final scan ImageData, plus one temporary + // surface and one ImageData buffer for every adjusted/mask layer. + const reservation = deps.reserve?.(pixels * 4 * (2 + layerCount * 2)); + if (reservation?.status === 'over-budget') { + throw new CompositeOverBudgetError(); + } + return reservation?.status === 'ok' ? reservation.lease : { release: () => undefined }; +}; + +/** The base composite's upload identity + hash. */ +export interface CompositeEntryResult { + /** The entry's stable plan key. */ + key: string; + /** The uploaded (or reused) image name. */ + imageName: string; + width: number; + height: number; + /** SHA-256 of the composited PNG bytes. */ + pixelHash: string; + /** True when this result came from cache/dedupe (no upload happened this call). */ + reusedUpload: boolean; +} + +/** The full result of executing a plan: the base image + mode-detection geometry. */ +export interface CompositeResult { + base: CompositeEntryResult; + /** Union of enabled raster content bounds in document space, or `null`. */ + contentBounds: Rect | null; + /** Whether the composited bbox surface is fully opaque (no transparent holes). */ + bboxFullyCovered: boolean; +} + +/** Document→bbox translate matrix (the "view" the entry is composited under). */ +const bboxView = (bbox: Rect): Mat2d => ({ a: 1, b: 0, c: 0, d: 1, e: -bbox.x, f: -bbox.y }); + +/** Applies a matrix to a 2D context's transform. */ +const setTransform = (ctx: Ctx, m: Mat2d): void => { + ctx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f); +}; + +/** + * Union of a plan's base-raster content bounds in document space, or `null` + * when the plan has no enabled raster content. Pure geometry (no pixels, no + * upload), so the invoke orchestrator can run it as a bounds-only pre-pass to + * decide txt2img (no bbox overlap) *before* paying for a composite/encode/upload. + */ +export const computeCompositeContentBounds = (plan: CompositePlan): Rect | null => { + const entry = plan.entries.find((e) => e.kind === 'base-raster'); + return entry ? getCompositeLayerBounds(entry.layers) : null; +}; + +/** True when every pixel of `imageData` is fully opaque (alpha === 255). Empty → false. */ +const isFullyOpaque = (imageData: ImageData): boolean => { + const { data, height, width } = imageData; + if (width <= 0 || height <= 0) { + return false; + } + for (let i = 3; i < data.length; i += 4) { + if (data[i] < 255) { + return false; + } + } + return true; +}; + +/** + * Composites, scans coverage, encodes, hashes, dedupes, and (when needed) + * uploads a single raster-style entry (`base-raster` or `control-layer`). + * Shared by {@link executeCompositePlan} and {@link executeControlComposite} so + * both go through the identical plan-key + content-hash dedupe path. + */ +const executeRasterEntry = async ( + entry: CompositeEntry, + deps: ExecuteCompositePlanDeps +): Promise => { + const hashBlob = deps.hashBlob ?? defaultHashBlob; + const readImageData = deps.readImageData ?? defaultReadImageData; + + // Plan-key hit: nothing that affects these pixels changed — reuse everything, + // no composite, no encode, no upload. + const cached = deps.dedupe.byKey.get(entry.key); + if (cached) { + return { + bboxFullyCovered: cached.bboxFullyCovered, + height: cached.height, + imageName: cached.imageName, + key: entry.key, + pixelHash: cached.pixelHash, + reusedUpload: true, + width: cached.width, + }; + } + + const reservation = reserveComposite( + entry, + deps, + entry.layers.filter((layer) => layer.adjustments !== undefined).length + ); + try { + const surface = await renderRasterComposite(entry, deps); + const bboxFullyCovered = isFullyOpaque( + readImageData(surface, { height: surface.height, width: surface.width, x: 0, y: 0 }) + ); + + const blob = await deps.backend.encodeSurface(surface); + const pixelHash = await hashBlob(blob); + + // Content-hash dedupe: identical pixels (even under a different key) reuse the + // already-uploaded image — no second upload. + let upload = deps.dedupe.byHash.get(pixelHash); + let reusedUpload = true; + if (!upload) { + upload = await deps.uploadImage(blob); + deps.dedupe.byHash.set(pixelHash, upload); + reusedUpload = false; + } + + deps.dedupe.byKey.set(entry.key, { + bboxFullyCovered, + height: upload.height, + imageName: upload.imageName, + pixelHash, + width: upload.width, + }); + + return { + bboxFullyCovered, + height: upload.height, + imageName: upload.imageName, + key: entry.key, + pixelHash, + reusedUpload, + width: upload.width, + }; + } finally { + reservation.release(); + } +}; + +/** + * Executes `plan`'s base-raster composite: composites, scans coverage, encodes, + * dedupes, and (when needed) uploads. Returns the base image identity plus the + * `contentBounds` / `bboxFullyCovered` facts the mode detector consumes. + */ +export const executeCompositePlan = async ( + plan: CompositePlan, + deps: ExecuteCompositePlanDeps +): Promise => { + const entry = plan.entries.find((e) => e.kind === 'base-raster'); + if (!entry) { + throw new Error('executeCompositePlan: plan has no base-raster entry'); + } + + const contentBounds = getCompositeLayerBounds(entry.layers); + const { bboxFullyCovered, ...base } = await executeRasterEntry(entry, deps); + + return { base, bboxFullyCovered, contentBounds }; +}; + +/** + * Executes a single `control-layer` composite entry (one enabled control layer, + * composited alone over the bbox). Reuses the same dedupe cache as the base + * composite, so an unchanged control layer skips re-upload across invokes. + */ +export const executeControlComposite = async ( + entry: CompositeEntry, + deps: ExecuteCompositePlanDeps +): Promise => { + const { bboxFullyCovered: _bboxFullyCovered, ...result } = await executeRasterEntry(entry, deps); + return result; +}; + +/** + * Executes a single `regional-mask` composite entry (one enabled regional-guidance + * layer's mask, composited alone over the bbox with its alpha preserved). The + * uploaded image's alpha channel is the region coverage, consumed by + * `alpha_mask_to_tensor`. Reuses the same dedupe cache + raster path as the base + * / control composites, so an unchanged region mask skips re-upload. + */ +export const executeRegionalMaskComposite = async ( + entry: CompositeEntry, + deps: ExecuteCompositePlanDeps +): Promise => { + const { bboxFullyCovered: _bboxFullyCovered, ...result } = await executeRasterEntry(entry, deps); + return result; +}; + +// ---- Grayscale mask composite (inpaint/outpaint) --------------------------- + +/** + * Converts a mask layer's alpha into legacy grayscale, in place: a masked pixel + * (alpha > 127) becomes `255 - round(255 * attributeValue)` (black at full + * strength), an unmasked pixel becomes white (255); alpha is forced opaque. This + * mirrors `getGrayscaleMaskCompositeImageDTO`'s per-pixel step so multiple masks + * can be darken-composited over a white background (dark = inpaint, white = keep). + */ +export const toGrayscaleMaskPixels = (imageData: ImageData, attributeValue: number): void => { + const { data } = imageData; + const masked = Math.max(0, Math.min(255, 255 - Math.round(255 * attributeValue))); + for (let i = 0; i + 3 < data.length; i += 4) { + const gray = (data[i + 3] ?? 0) > 127 ? masked : 255; + data[i] = gray; + data[i + 1] = gray; + data[i + 2] = gray; + data[i + 3] = 255; + } +}; + +/** The local→document transform matrix for a mask layer ref. */ +const maskLayerMatrix = (ref: CompositeMaskLayerRef): Mat2d => + fromTRS( + { x: ref.transform.x, y: ref.transform.y }, + ref.transform.rotation, + ref.transform.scaleX, + ref.transform.scaleY + ); + +/** True when any pixel is non-white (a masked region exists). Empty → false. */ +const hasNonWhitePixel = (imageData: ImageData): boolean => { + const { data, height, width } = imageData; + if (width <= 0 || height <= 0) { + return false; + } + for (let i = 0; i + 3 < data.length; i += 4) { + if ((data[i] ?? 255) < 255) { + return true; + } + } + return false; +}; + +/** The result of a grayscale mask composite: its upload identity + whether it has any masked pixels. */ +export interface MaskCompositeResult { + key: string; + imageName: string; + width: number; + height: number; + pixelHash: string; + reusedUpload: boolean; + /** True when the composite contains a masked (non-white) region within the bbox. */ + hasContent: boolean; +} + +/** Composites one mask entry's layers into a grayscale bbox surface (white bg, darken combine). */ +const compositeMaskEntry = async ( + entry: CompositeEntry, + deps: ExecuteCompositePlanDeps, + writeImageData: (surface: RasterSurface, imageData: ImageData, x: number, y: number) => void, + readImageData: (surface: RasterSurface, rect: Rect) => ImageData +): Promise => { + const { bbox } = entry; + const maskLayers = entry.maskLayers ?? []; + const width = Math.max(0, bbox.width); + const height = Math.max(0, bbox.height); + const accumulator = deps.backend.createSurface(width, height); + const accCtx = accumulator.ctx; + + // White background: unmasked area stays white ("keep"). + setTransform(accCtx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + accCtx.fillStyle = 'white'; + accCtx.fillRect(0, 0, width, height); + + const view = bboxView(bbox); + const fullRect: Rect = { height, width, x: 0, y: 0 }; + + for (const ref of maskLayers) { + // Render the mask alpha into a temp bbox surface through its transform. + const temp = deps.backend.createSurface(width, height); + const tempCtx = temp.ctx; + setTransform(tempCtx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + tempCtx.clearRect(0, 0, width, height); + const layerSurface = await deps.getLayerSurface(ref.id); + setTransform(tempCtx, multiply(view, maskLayerMatrix(ref))); + tempCtx.drawImage(layerSurface.surface.canvas, layerSurface.rect.x, layerSurface.rect.y); + + // Convert its alpha to grayscale by the layer's attribute value. + const pixels = readImageData(temp, fullRect); + toGrayscaleMaskPixels(pixels, ref.attributeValue); + writeImageData(temp, pixels, 0, 0); + + // Darken-combine onto the accumulator (min per channel), matching legacy. + setTransform(accCtx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + accCtx.globalAlpha = 1; + accCtx.globalCompositeOperation = 'darken'; + accCtx.drawImage(temp.canvas, 0, 0); + } + + accCtx.globalCompositeOperation = 'source-over'; + return accumulator; +}; + +/** + * Executes a grayscale mask composite entry (`inpaint-mask` / `noise-mask`): + * composites the mask layers into a white-backed grayscale image, scans whether + * any masked region exists, then encodes / dedupes / uploads exactly like the + * base composite. Content-hash + plan-key dedupe reuse the same caller-owned + * cache so an unchanged mask skips re-upload. + */ +export const executeMaskComposite = async ( + entry: CompositeEntry, + deps: ExecuteCompositePlanDeps +): Promise => { + const hashBlob = deps.hashBlob ?? defaultHashBlob; + const readImageData = deps.readImageData ?? defaultReadImageData; + const writeImageData = deps.writeImageData ?? defaultWriteImageData; + + const cached = deps.dedupe.byKey.get(entry.key); + if (cached) { + return { + hasContent: cached.bboxFullyCovered, + height: cached.height, + imageName: cached.imageName, + key: entry.key, + pixelHash: cached.pixelHash, + reusedUpload: true, + width: cached.width, + }; + } + + const reservation = reserveComposite(entry, deps, entry.maskLayers?.length ?? 0); + try { + const surface = await compositeMaskEntry(entry, deps, writeImageData, readImageData); + const hasContent = hasNonWhitePixel( + readImageData(surface, { height: surface.height, width: surface.width, x: 0, y: 0 }) + ); + + const blob = await deps.backend.encodeSurface(surface); + const pixelHash = await hashBlob(blob); + + let upload = deps.dedupe.byHash.get(pixelHash); + let reusedUpload = true; + if (!upload) { + upload = await deps.uploadImage(blob); + deps.dedupe.byHash.set(pixelHash, upload); + reusedUpload = false; + } + + // Reuse the `bboxFullyCovered` slot to persist `hasContent` for this key. + deps.dedupe.byKey.set(entry.key, { + bboxFullyCovered: hasContent, + height: upload.height, + imageName: upload.imageName, + pixelHash, + width: upload.width, + }); + + return { + hasContent, + height: upload.height, + imageName: upload.imageName, + key: entry.key, + pixelHash, + reusedUpload, + width: upload.width, + }; + } finally { + reservation.release(); + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/contracts.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/contracts.ts new file mode 100644 index 00000000000..34ba5a4ad64 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/contracts.ts @@ -0,0 +1,364 @@ +import type { + CommitGeneratedImageResult, + LayerExportGuard, + ReplaceSelectionFromImageResult, +} from '@workbench/canvas-engine/api'; +import type { CommitRasterFilterResult } from '@workbench/canvas-engine/controllers/filterResultController'; +import type { + CommitMaskImageResult, + MaskImageResultTarget, +} from '@workbench/canvas-engine/controllers/maskResultController'; +import type { CanvasEditGate } from '@workbench/canvas-engine/editGate'; +export type { CanvasCompositeExecutorDeps } from '@workbench/canvas-engine/api'; +import type { ExportBakedLayerBlobResult, ExportLayerPixelsResult } from '@workbench/canvas-engine/engine'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { + FilterCommitTarget, + FilterOperationSessionState, + LayerFilterSettings, + SamInput, + SamModel, + SamSessionError, + SamSessionSnapshot, +} from '@workbench/canvas-operations/operationTypes'; +import type { BackendGraphContract, CanvasDocumentContractV2, CanvasImageRef, WorkbenchState } from '@workbench/types'; + +export type StartSelectObjectSessionResult = + | 'started' + | 'missing' + | 'disabled' + | 'locked' + | 'unsupported' + | 'not-ready'; +export type StartFilterOperationResult = 'started' | 'missing' | 'disabled' | 'locked' | 'unsupported' | 'not-ready'; +export type CanvasOperationActionResult = 'completed' | 'blocked' | 'stale'; +export type FilterCommitOperationResult = 'committed' | 'blocked' | 'stale'; +export type CanvasOperationMutationResult = 'updated' | 'blocked' | 'stale'; +export type SelectObjectSaveTarget = 'selection' | 'raster' | 'control' | MaskImageResultTarget; +export type SaveSelectObjectSessionResult = + | CommitGeneratedImageResult + | CommitMaskImageResult + | ReplaceSelectionFromImageResult; +export interface SelectObjectSessionUpdate { + input?: SamInput; + pointLabel?: 'include' | 'exclude'; + model?: SamModel; + invert?: boolean; + applyPolygonRefinement?: boolean; + autoProcess?: boolean; + isolatedPreview?: boolean; +} + +export type CanvasOperationIdentity = + | { kind: 'select-object'; projectId: string; layerId: string } + | { kind: 'filter'; projectId: string; layerId: string }; +export type CanvasOperationState = + | { status: 'idle' } + | { status: 'active'; identity: CanvasOperationIdentity; phase: 'ready' | 'running' | 'error'; error: string | null }; +export type CanvasOperationRunResult = 'published' | 'stale' | 'error'; +export interface CanvasOperationSession { + run( + work: (signal: AbortSignal) => Promise, + commitPreview: (result: T) => undefined + ): Promise; + reset(): void; + interruptProcessing(): void; + cancel(): void; +} +export interface CanvasOperationController { + getSnapshot(): CanvasOperationState; + subscribe(listener: () => void): () => void; + start(options: { + identity: CanvasOperationIdentity; + guard: LayerExportGuard; + cleanupPreview(): void; + }): CanvasOperationSession | null; + reset(): void; + cancel(): void; + invalidateSource(projectId: string, layerId: string): void; + invalidateLayer(projectId: string, layerId: string): void; + invalidateProject(projectId: string): void; + invalidateDocument(projectId: string): void; + dispose(): void; +} + +export interface CanvasApplicationScalarStore { + get(): T; + set(value: T): void; + subscribe(listener: () => void): () => void; +} + +export interface CanvasApplicationOperationStores { + readonly filterSession: CanvasApplicationScalarStore; + readonly samSession: CanvasApplicationScalarStore; +} + +export interface CanvasFilterCoordinatorDeps { + readonly stores: CanvasApplicationOperationStores; + readonly controller: CanvasOperationController; + isInteractionLocked(): boolean; + getDocument(): CanvasDocumentContractV2 | null; + captureGuard(layerId: string): LayerExportGuard | null; + selectLayer(layerId: string): void; + clearOtherOperation(): void; + clearPreview(layerId: string): void; + setViewTool(): void; + encodeSurface(surface: RasterSurface): Promise; + runFilterGraph(options: { + graph: BackendGraphContract; + outputNodeId?: string; + signal?: AbortSignal; + }): Promise<{ height: number; imageName: string; width: number }>; + uploadIntermediate(blob: Blob, signal?: AbortSignal): Promise<{ imageName: string }>; + createSession?(options: CreateFilterSessionOptions): FilterOperationSession | null; + getSessionDeps( + layerId: string + ): Omit< + CreateFilterSessionOptions['deps'], + 'canCommit' | 'clearPreview' | 'controller' | 'isDraftValid' | 'makeDurable' | 'runFilter' + >; +} + +export interface CanvasFilterOperationCoordinator { + start(layerId: string, recommendedFilterType?: string | null): StartFilterOperationResult; + updateDraft(draft: LayerFilterSettings): CanvasOperationMutationResult; + setAutoProcess(value: boolean): CanvasOperationMutationResult; + process(): Promise; + reset(settings: Record): CanvasOperationMutationResult; + commit( + target: FilterCommitTarget, + makeImageDurable: (imageName: string) => Promise + ): Promise; + cancel(): void; + interruptAndBlock(): void; + isActive(): boolean; + dispose(): void; +} + +export type SelectObjectStartContext = + | { status: Exclude } + | { + status: 'ready'; + guard: LayerExportGuard; + layerId: string; + layerName: string; + layerType: 'raster' | 'control'; + sourceRect: Rect; + }; + +export interface CanvasSelectObjectCoordinatorDeps { + readonly stores: CanvasApplicationOperationStores; + readonly controller: CanvasOperationController; + readonly projectId: string; + isInteractionLocked(): boolean; + prepareStart(layerId: string): SelectObjectStartContext; + selectLayer(layerId: string): void; + clearOtherOperation(): void; + setSamTool(): void; + setViewTool(): void; + replaceTemporaryRestoreTool(): void; + isSamToolActive(): boolean; + captureGuard(layerId: string): LayerExportGuard | null; + isGuardCurrent(guard: LayerExportGuard): boolean; + exportSource(layerId: string): Promise; + uploadIntermediate(blob: Blob, signal?: AbortSignal): Promise<{ height: number; imageName: string; width: number }>; + runGraph(options: { + graph: BackendGraphContract; + outputNodeId?: string; + signal?: AbortSignal; + }): Promise; + decodePreview(result: SelectObjectReadyResult, signal: AbortSignal): Promise; + publishPreview(preview: SelectObjectSessionPreview): void; + clearPreview(): void; + invalidateOverlay(): void; + commitGenerated( + preview: SelectObjectSessionPreview, + options: { mode: 'replace' | 'copy-raster' | 'copy-control'; signal: AbortSignal } + ): Promise; + commitMask( + preview: SelectObjectSessionPreview, + target: MaskImageResultTarget, + signal: AbortSignal + ): Promise; + replaceSelection( + preview: SelectObjectSessionPreview, + signal: AbortSignal + ): Promise; + createSession?(options: CreateSelectObjectSessionOptions): SelectObjectSession; +} + +export interface CanvasSelectObjectOperationCoordinator { + start(layerId: string): StartSelectObjectSessionResult; + update(changes: SelectObjectSessionUpdate): CanvasOperationMutationResult; + process(): Promise; + apply(makeImageDurable: (imageName: string) => Promise): Promise; + save( + target: SelectObjectSaveTarget, + makeImageDurable: (imageName: string) => Promise + ): Promise; + reset(): CanvasOperationMutationResult; + cancel(): void; + interruptAndBlock(): void; + isActive(): boolean; + dispose(): void; +} + +export interface FilterOperationSession { + getSnapshot(): FilterOperationSessionState; + subscribe(listener: () => void): () => void; + updateDraft(draft: LayerFilterSettings): void; + setAutoProcess(value: boolean): void; + process(): Promise; + interruptProcessing(): void; + reset(settings: Record): void; + commit(target: FilterCommitTarget): Promise<'committed' | 'blocked' | 'stale'>; + blockCommit(): void; + cancel(): void; + dispose(): void; +} + +export type SelectObjectSessionStatus = + | 'ready' + | 'scheduled' + | 'preparing-source' + | 'uploading' + | 'processing-sam' + | 'rendering-preview' + | 'error'; +export interface SelectObjectSessionPreview { + data: T; + guard: LayerExportGuard; + image: CanvasImageRef; + inputHash: string; + previewId: number; + isolated: boolean; + rect: Rect; + sourceImageName: string; +} +export interface SelectObjectSessionState { + input: SamInput; + model: SamModel; + invert: boolean; + applyPolygonRefinement: boolean; + autoProcess: boolean; + isolatedPreview: boolean; + status: SelectObjectSessionStatus; + error: SamSessionError | null; + preview: SelectObjectSessionPreview | null; + sourceGuard: LayerExportGuard | null; +} +export type SelectObjectSessionProcessResult = CanvasOperationRunResult | 'blocked' | 'deduped' | 'invalid'; +export interface SelectObjectSession { + getSnapshot(): SelectObjectSessionState; + subscribe(listener: () => void): () => void; + update( + changes: Partial< + Pick< + SelectObjectSessionState, + 'applyPolygonRefinement' | 'autoProcess' | 'input' | 'invert' | 'isolatedPreview' | 'model' + > + > + ): void; + process(): Promise; + interruptProcessing(): void; + reportError(error: SamSessionError | string): void; + reset(): void; + cancel(): void; + dispose(): void; +} + +export interface CanvasUtilityGraphResult { + imageName: string; + width: number; + height: number; +} +export interface LayerFilterResult { + imageName: string; + width: number; + height: number; + origin: { x: number; y: number }; +} + +export interface CreateFilterSessionOptions { + deps: { + controller: CanvasOperationController; + exportPixels(): Promise; + runFilter(options: { + filterType: string; + input: { surface: RasterSurface; rect: Rect }; + settings: Record; + signal: AbortSignal; + }): Promise; + publishPreview( + imageName: string, + rect: Rect, + guard: LayerExportGuard, + filterType: string + ): Promise<'shown' | 'missing' | 'stale'>; + clearPreview(): void; + isGuardCurrent(guard: LayerExportGuard): boolean; + isDraftValid(draft: LayerFilterSettings): boolean; + makeDurable(imageName: string): Promise; + canCommit(): boolean; + commit(options: { + draft: LayerFilterSettings; + guard: LayerExportGuard; + image: { imageName: string; width: number; height: number }; + origin: { x: number; y: number }; + rect: Rect; + signal: AbortSignal; + target: FilterCommitTarget; + }): Promise; + }; + guard: LayerExportGuard; + initialFilter: LayerFilterSettings | null; + initialDraft?: LayerFilterSettings; + layerName?: string; + layerType: 'raster' | 'control'; +} + +export interface SelectObjectReadyResult { + status: 'ready'; + image: CanvasImageRef; + rect: Rect; + guard: LayerExportGuard; +} +export interface CreateSelectObjectSessionOptions { + deps: { + captureGuard(): LayerExportGuard | null; + controller: CanvasOperationController; + exportSource(): Promise; + uploadIntermediate(blob: Blob, signal?: AbortSignal): Promise<{ height: number; imageName: string; width: number }>; + runGraph(options: { + graph: BackendGraphContract; + outputNodeId?: string; + signal?: AbortSignal; + }): Promise; + decodePreview(result: SelectObjectReadyResult, signal: AbortSignal): Promise; + publishPreview(preview: SelectObjectSessionPreview): undefined; + cleanupPreview(): void; + isGuardCurrent(guard: LayerExportGuard): boolean; + }; + operation: CanvasOperationSession; +} + +export interface CanvasApplicationPort { + createFilterCoordinator(deps: CanvasFilterCoordinatorDeps): CanvasFilterOperationCoordinator; + createSelectObjectCoordinator(deps: CanvasSelectObjectCoordinatorDeps): CanvasSelectObjectOperationCoordinator; + createOperationStores(): CanvasApplicationOperationStores; + createOperationController(deps: { + edits: CanvasEditGate; + isGuardCurrent(guard: LayerExportGuard): boolean; + }): CanvasOperationController; + runGraph(options: { + graph: BackendGraphContract; + outputNodeId?: string; + signal?: AbortSignal; + }): Promise; + uploadImage( + blob: Blob, + options?: { isIntermediate?: boolean; signal?: AbortSignal } + ): Promise<{ height: number; imageName: string; width: number }>; + getSelectedModelBase(state: WorkbenchState, projectId: string): string | null; +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/createCanvasEngine.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/createCanvasEngine.ts new file mode 100644 index 00000000000..205e71e4b96 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/createCanvasEngine.ts @@ -0,0 +1,282 @@ +import type { + CanvasEngine as CoreCanvasEngine, + CanvasEngineOptions as CoreCanvasEngineOptions, +} from '@workbench/canvas-engine/engine'; +import type { CanvasUtilityGraphResult } from '@workbench/canvas-operations/contracts'; +import type { BackendGraphContract } from '@workbench/types'; + +import { createCanvasEngine as createCanvasEngineCore } from '@workbench/canvas-engine/engine'; +import { canvasApplicationPort } from '@workbench/canvas-operations/applicationPort'; + +import type { CanvasOperationCapability } from './api'; + +export interface CanvasEngineOptions extends Omit { + getMainModelBase?: () => string | null; + selectObjectDeps?: { + uploadIntermediate(blob: Blob, signal?: AbortSignal): Promise<{ height: number; imageName: string; width: number }>; + runGraph(options: { + graph: BackendGraphContract; + outputNodeId?: string; + signal?: AbortSignal; + }): Promise; + }; + filterDeps?: { + uploadIntermediate(blob: Blob, signal?: AbortSignal): Promise<{ imageName: string }>; + runGraph(options: { + graph: BackendGraphContract; + outputNodeId?: string; + signal?: AbortSignal; + }): Promise; + }; +} + +export type CanvasOperationsCapability = CanvasOperationCapability; +export type CanvasEngine = CoreCanvasEngine; + +const operationsByEngine = new WeakMap(); + +export const getCanvasOperations = (engine: object): CanvasOperationCapability => { + const operations = operationsByEngine.get(engine); + if (!operations) { + throw new Error('Canvas application operations are unavailable for this engine.'); + } + return operations; +}; + +/** Application composition root: owns SAM/filter sessions, queues, uploads, and their core capability adapters. */ +export const createCanvasEngine = (options: CanvasEngineOptions): CanvasEngine => { + const { filterDeps, selectObjectDeps, ...coreOptions } = options; + const composition = createCanvasEngineCore({ + ...coreOptions, + getMainModelBase: options.getMainModelBase, + uploadImage: (blob) => canvasApplicationPort.uploadImage(blob), + }); + const { applicationHost: host } = composition; + const core = composition.engine; + const stores = canvasApplicationPort.createOperationStores(); + const controller = canvasApplicationPort.createOperationController({ + edits: core.edits, + isGuardCurrent: host.isGuardCurrent, + }); + + let filterCoordinator!: ReturnType; + const selectObjectCoordinator = canvasApplicationPort.createSelectObjectCoordinator({ + captureGuard: host.captureGuard, + clearOtherOperation: () => filterCoordinator.cancel(), + clearPreview: host.clearSamPreview, + commitGenerated: (preview, commitOptions) => + host.commitGenerated({ + copyLayerName: + commitOptions.mode === 'copy-raster' + ? 'Segmented Object' + : commitOptions.mode === 'copy-control' + ? 'Segmented Object Control' + : undefined, + guard: preview.guard, + historyLabel: commitOptions.mode === 'replace' ? 'Replace layer with selected object' : undefined, + image: preview.image, + origin: { x: preview.rect.x, y: preview.rect.y }, + signal: commitOptions.signal, + target: + commitOptions.mode === 'replace' + ? 'replace' + : commitOptions.mode === 'copy-raster' + ? 'copy-raster' + : 'copy-control', + }), + commitMask: (preview, target, signal) => + host.commitMask({ guard: preview.guard, image: preview.image, rect: preview.rect, signal, target }), + controller, + decodePreview: host.decodeSelectObjectPreview, + exportSource: host.exportBakedLayerBlob, + invalidateOverlay: () => { + const session = stores.samSession.get(); + host.setSamInteraction( + session?.input.type === 'visual' + ? { input: session.input, pointLabel: session.pointLabel, sourceRect: session.sourceRect } + : null + ); + }, + isGuardCurrent: host.isGuardCurrent, + isInteractionLocked: host.isInteractionLocked, + isSamToolActive: host.isSamToolActive, + prepareStart: host.prepareSelectObjectStart, + projectId: options.projectId, + publishPreview: (preview) => host.publishSamPreview(preview), + replaceSelection: (preview, signal) => host.replaceSelection(preview.guard, preview.image, preview.rect, signal), + replaceTemporaryRestoreTool: host.replaceTemporaryRestoreTool, + runGraph: (runOptions) => selectObjectDeps?.runGraph(runOptions) ?? canvasApplicationPort.runGraph(runOptions), + selectLayer: host.selectLayer, + setSamTool: host.setSamTool, + setViewTool: host.setViewTool, + stores, + uploadIntermediate: async (blob, signal) => { + if (selectObjectDeps) { + return selectObjectDeps.uploadIntermediate(blob, signal); + } + if (signal?.aborted) { + throw new DOMException('Select Object upload was aborted.', 'AbortError'); + } + const uploaded = await canvasApplicationPort.uploadImage(blob, { isIntermediate: true, signal }); + if (signal?.aborted) { + throw new DOMException('Select Object upload was aborted.', 'AbortError'); + } + return uploaded; + }, + }); + + filterCoordinator = canvasApplicationPort.createFilterCoordinator({ + captureGuard: host.captureGuard, + clearOtherOperation: () => selectObjectCoordinator.cancel(), + clearPreview: host.clearFilterPreview, + controller, + encodeSurface: host.encodeSurface, + getDocument: host.getDocument, + getSessionDeps: (layerId) => ({ + commit: ({ draft, guard, image, rect, signal, target }) => { + if (host.isInteractionLocked()) { + return Promise.resolve({ status: 'locked' }); + } + return host.commitFilter({ + filter: draft, + guard, + image, + mode: target === 'apply' ? 'replace' : 'copy', + rect, + requireExactImageDimensions: true, + signal, + target, + }); + }, + exportPixels: () => host.exportLayerPixels(layerId, { applyAdjustments: true, includeDisabled: true }), + isGuardCurrent: host.isGuardCurrent, + publishPreview: (imageName, rect, guard, filterType) => + host.publishFilterPreview(guard.layerId, imageName, rect, guard, filterType), + }), + isInteractionLocked: host.isInteractionLocked, + runFilterGraph: async (runOptions) => { + const output = await (filterDeps?.runGraph(runOptions) ?? canvasApplicationPort.runGraph(runOptions)); + return { height: output.height, imageName: output.imageName, width: output.width }; + }, + selectLayer: host.selectLayer, + setViewTool: host.setViewTool, + stores, + uploadIntermediate: async (blob, signal) => { + const uploaded = await (filterDeps?.uploadIntermediate(blob, signal) ?? + canvasApplicationPort.uploadImage(blob, { isIntermediate: true, signal })); + return { imageName: uploaded.imageName }; + }, + }); + + const syncEditingLock = (): void => + core.stores.documentEditingLocked.set(controller.getSnapshot().status === 'active'); + const unsubscribeEditingLock = controller.subscribe(syncEditingLock); + const syncSamInteraction = (): void => { + const session = stores.samSession.get(); + host.setSamInteraction( + session?.input.type === 'visual' + ? { input: session.input, pointLabel: session.pointLabel, sourceRect: session.sourceRect } + : null + ); + }; + const unsubscribeSamInteraction = stores.samSession.subscribe(syncSamInteraction); + const unsubscribeToolChanges = host.subscribeToolChanges(({ from, temporary, to }) => { + if (from === 'sam' && to !== 'sam' && !temporary && selectObjectCoordinator.isActive()) { + selectObjectCoordinator.cancel(); + } + }); + host.setSamInputHandler((input) => selectObjectCoordinator.update({ input })); + host.setEscapeHandler((gestureWasActive) => { + if (gestureWasActive) { + return false; + } + if (filterCoordinator.isActive()) { + filterCoordinator.cancel(); + return true; + } + if (selectObjectCoordinator.isActive()) { + selectObjectCoordinator.cancel(); + return true; + } + return false; + }); + syncEditingLock(); + syncSamInteraction(); + + const operations: CanvasOperationCapability = { + applySelectObjectSession: selectObjectCoordinator.apply, + cancelFilterOperation: filterCoordinator.cancel, + cancelSelectObjectSession: selectObjectCoordinator.cancel, + commitFilterOperation: filterCoordinator.commit, + controller, + processFilterOperation: filterCoordinator.process, + processSelectObjectSession: selectObjectCoordinator.process, + resetFilterOperation: filterCoordinator.reset, + resetSelectObjectSession: selectObjectCoordinator.reset, + saveSelectObjectSession: selectObjectCoordinator.save, + setFilterOperationAutoProcess: filterCoordinator.setAutoProcess, + startFilterOperation: filterCoordinator.start, + startSelectObject: selectObjectCoordinator.start, + stores, + updateFilterOperation: filterCoordinator.updateDraft, + updateSelectObjectSession: selectObjectCoordinator.update, + }; + + const coreSetInteractionLocked = core.tools.setInteractionLocked; + const tools = { + ...core.tools, + setInteractionLocked: (locked: boolean) => { + if (locked) { + selectObjectCoordinator.interruptAndBlock(); + filterCoordinator.interruptAndBlock(); + } + coreSetInteractionLocked(locked); + if (!locked && selectObjectCoordinator.isActive()) { + host.setSamTool(); + } + }, + }; + + let disposed = false; + const disposeOperations = (): void => { + if (disposed) { + return; + } + disposed = true; + host.setSamInputHandler(null); + host.setEscapeHandler(null); + unsubscribeToolChanges(); + unsubscribeSamInteraction(); + unsubscribeEditingLock(); + filterCoordinator.dispose(); + selectObjectCoordinator.dispose(); + controller.dispose(); + core.stores.documentEditingLocked.set(false); + host.setSamInteraction(null); + }; + const lifecycle = { + ...core.lifecycle, + beginCooldown: () => { + filterCoordinator.cancel(); + selectObjectCoordinator.cancel(); + controller.invalidateDocument(options.projectId); + return core.lifecycle.beginCooldown(); + }, + dispose: () => { + disposeOperations(); + core.lifecycle.dispose(); + }, + }; + const diagnostics = { + ...core.diagnostics, + clearCaches: async () => { + filterCoordinator.cancel(); + selectObjectCoordinator.cancel(); + await core.diagnostics.clearCaches(); + }, + }; + + const engine: CanvasEngine = { ...core, diagnostics, lifecycle, tools }; + operationsByEngine.set(engine, operations); + return engine; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/engineRegistry.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/engineRegistry.test.ts new file mode 100644 index 00000000000..acffc3885fe --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/engineRegistry.test.ts @@ -0,0 +1,387 @@ +import type { + CanvasControlLayerContract, + CanvasDocumentContractV2, + CanvasRegionalGuidanceLayerContract, +} from '@workbench/types'; + +import { createBitmapStore } from '@workbench/canvas-engine/document/bitmapStore'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { + createCompositeDedupeCache, + executeControlComposite, + executeRegionalMaskComposite, +} from '@workbench/canvas-operations/compositeForGeneration'; +import { createEmptyCanvasDocumentV2 } from '@workbench/canvasMigration'; +import { applyCanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import { planControlComposites, planRegionalMaskComposites } from '@workbench/generation/canvas/compositePlan'; +import { createInitialWorkbenchState } from '@workbench/workbenchState'; +import { describe, expect, it, vi } from 'vitest'; + +import type { EngineDeps, RegistryTimers } from './engineRegistry'; + +import { createEngineRegistry } from './engineRegistry'; + +const createDeferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +const drainUntil = async (predicate: () => boolean, maxTicks = 50): Promise => { + for (let tick = 0; tick < maxTicks && !predicate(); tick += 1) { + await Promise.resolve(); + } +}; + +const createFakeDeps = (): EngineDeps => { + let project = createInitialWorkbenchState().projects[0]!; + const listeners = new Set<() => void>(); + return { + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + mutationPort: { + dispatch: (mutation) => { + const before = project.canvas; + project = applyCanvasProjectMutation(project, mutation); + for (const listener of listeners) { + listener(); + } + return project.canvas !== before; + }, + getCanvasState: () => project.canvas, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + reportError: vi.fn(), + }; +}; + +const controlLayer = (id: string): CanvasControlLayerContract => ({ + adapter: { + beginEndStepPct: [0, 1], + controlMode: 'balanced', + kind: 'controlnet', + model: null, + weight: 0.75, + }, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height: 64, imageName: `${id}.png`, width: 64 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: true, +}); + +const regionalLayer = (id: string): CanvasRegionalGuidanceLayerContract => ({ + autoNegative: false, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { + bitmap: { height: 64, imageName: `${id}-mask.png`, width: 64 }, + fill: { color: '#ff0000', style: 'solid' }, + }, + name: id, + negativePrompt: null, + opacity: 1, + positivePrompt: 'regional prompt', + referenceImages: [], + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'regional_guidance', +}); + +const invocationDocument = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 64, width: 64, x: 0, y: 0 }, + height: 64, + layers: [controlLayer('control-a'), regionalLayer('regional-a')], + selectedLayerId: 'control-a', + version: 2, + width: 64, +}); + +const createFakeTimers = (): { timers: RegistryTimers; flush: () => void; pending: () => number } => { + let nextHandle = 0; + const scheduled = new Map void>(); + return { + flush: () => { + const callbacks = [...scheduled.values()]; + scheduled.clear(); + for (const callback of callbacks) { + callback(); + } + }, + pending: () => scheduled.size, + timers: { + clearTimeout: (handle) => { + scheduled.delete(handle); + }, + setTimeout: (handler) => { + nextHandle += 1; + scheduled.set(nextHandle, handler); + return nextHandle; + }, + }, + }; +}; + +describe('createEngineRegistry', () => { + it('returns the same instance per project id and distinct instances across ids', () => { + const registry = createEngineRegistry(); + const deps = createFakeDeps(); + + const first = registry.getOrCreateEngine('p1', deps); + const again = registry.getOrCreateEngine('p1', deps); + const other = registry.getOrCreateEngine('p2', deps); + + expect(again).toBe(first); + expect(other).not.toBe(first); + expect(registry.getEngine('p1')).toBe(first); + + first.lifecycle.dispose(); + other.lifecycle.dispose(); + }); + + it('disposes after the grace period once the last reference is released', async () => { + const fakeTimers = createFakeTimers(); + const registry = createEngineRegistry({ gracePeriodMs: 30_000, timers: fakeTimers.timers }); + const engine = registry.getOrCreateEngine('p1', createFakeDeps()); + const disposeSpy = vi.spyOn(engine.lifecycle, 'dispose'); + const cooldownSpy = vi.spyOn(engine.lifecycle, 'beginCooldown'); + + registry.releaseEngine('p1'); + expect(fakeTimers.pending()).toBe(1); + expect(cooldownSpy).toHaveBeenCalledOnce(); + expect(disposeSpy).not.toHaveBeenCalled(); + + fakeTimers.flush(); + await Promise.resolve(); + await Promise.resolve(); + expect(disposeSpy).toHaveBeenCalledTimes(1); + expect(registry.getEngine('p1')).toBeUndefined(); + }); + + it('retains a dirty engine and retries cooldown before disposing it', async () => { + const fakeTimers = createFakeTimers(); + const registry = createEngineRegistry({ gracePeriodMs: 30_000, timers: fakeTimers.timers }); + const engine = registry.getOrCreateEngine('p1', createFakeDeps()); + const disposeSpy = vi.spyOn(engine.lifecycle, 'dispose'); + const cooldownSpy = vi + .spyOn(engine.lifecycle, 'beginCooldown') + .mockResolvedValueOnce('dirty') + .mockResolvedValueOnce('cooled'); + + registry.releaseEngine('p1'); + fakeTimers.flush(); + await Promise.resolve(); + await Promise.resolve(); + + expect(registry.getEngine('p1')).toBe(engine); + expect(disposeSpy).not.toHaveBeenCalled(); + expect(cooldownSpy).toHaveBeenCalledTimes(2); + expect(fakeTimers.pending()).toBe(1); + + fakeTimers.flush(); + await Promise.resolve(); + await Promise.resolve(); + + expect(registry.getEngine('p1')).toBeUndefined(); + expect(disposeSpy).toHaveBeenCalledOnce(); + }); + + it('cancels the pending disposal when the engine is re-acquired', () => { + const fakeTimers = createFakeTimers(); + const registry = createEngineRegistry({ timers: fakeTimers.timers }); + const engine = registry.getOrCreateEngine('p1', createFakeDeps()); + const disposeSpy = vi.spyOn(engine.lifecycle, 'dispose'); + const activateSpy = vi.spyOn(engine.lifecycle, 'activate'); + + registry.releaseEngine('p1'); + expect(fakeTimers.pending()).toBe(1); + + const reacquired = registry.getOrCreateEngine('p1', createFakeDeps()); + expect(reacquired).toBe(engine); + expect(fakeTimers.pending()).toBe(0); + expect(activateSpy).toHaveBeenCalledOnce(); + + fakeTimers.flush(); + expect(disposeSpy).not.toHaveBeenCalled(); + + engine.lifecycle.dispose(); + }); + + it('continues control and regional composites from a detached snapshot after a project switch', async () => { + const fakeTimers = createFakeTimers(); + const registry = createEngineRegistry({ timers: fakeTimers.timers }); + const projectADeps = createFakeDeps(); + projectADeps.mutationPort.dispatch({ document: invocationDocument(), type: 'replaceCanvasDocument' }); + const projectAEngine = registry.getOrCreateEngine('project-a', projectADeps); + const documentSnapshot = projectAEngine.document.captureSnapshot(); + expect(documentSnapshot).not.toBeNull(); + + const capture = await projectAEngine.exports.captureRasterSnapshot(documentSnapshot!, ['control-a', 'regional-a']); + expect(capture.status).toBe('ok'); + if (capture.status !== 'ok') { + throw new Error('Expected a detached raster snapshot'); + } + + registry.releaseEngine('project-a'); + const projectBEngine = registry.getOrCreateEngine('project-b', createFakeDeps()); + await drainUntil(() => projectAEngine.lifecycle.getLifecycleState() === 'cool'); + + expect(projectAEngine.lifecycle.getLifecycleState()).toBe('cool'); + expect(registry.getEngine('project-a')).toBe(projectAEngine); + expect(registry.getEngine('project-b')).toBe(projectBEngine); + expect(capture.snapshot.layerSurfaces.size).toBe(2); + + const frozenDocument = capture.snapshot.canvas.document; + const controlPlan = planControlComposites(frozenDocument, frozenDocument.bbox)[0]; + const regionalPlan = planRegionalMaskComposites(frozenDocument, frozenDocument.bbox)[0]; + expect(controlPlan).toBeDefined(); + expect(regionalPlan).toBeDefined(); + + const surfaceRequests: string[] = []; + const uploadImage = vi.fn((blob: Blob) => { + void blob; + return Promise.resolve({ height: 64, imageName: `snapshot-${surfaceRequests.length}.png`, width: 64 }); + }); + const executorDeps = { + ...projectAEngine.exports.getCompositeExecutorDeps(), + dedupe: createCompositeDedupeCache(), + getLayerSurface: (layerId: string) => { + surfaceRequests.push(layerId); + const detached = capture.snapshot.layerSurfaces.get(layerId); + if (!detached) { + throw new Error(`Detached snapshot is missing ${layerId}`); + } + return Promise.resolve(detached); + }, + hashBlob: (blob: Blob) => blob.text(), + uploadImage, + }; + + await executeControlComposite(controlPlan!.entry, executorDeps); + await executeRegionalMaskComposite(regionalPlan!.entry, executorDeps); + + expect(surfaceRequests).toEqual(['control-a', 'regional-a']); + expect(uploadImage).toHaveBeenCalledTimes(1); + expect(capture.snapshot.layerSurfaces.size).toBe(2); + + capture.snapshot.release(); + registry.releaseEngine('project-b'); + fakeTimers.flush(); + await drainUntil( + () => registry.getEngine('project-a') === undefined && registry.getEngine('project-b') === undefined + ); + }); + + it('keeps a pending bitmap upload alive when the engine reaches zero references and is reacquired', async () => { + const fakeTimers = createFakeTimers(); + const registry = createEngineRegistry({ timers: fakeTimers.timers }); + let project = createInitialWorkbenchState().projects[0]!; + project = applyCanvasProjectMutation(project, { + document: createEmptyCanvasDocumentV2(), + type: 'replaceCanvasDocument', + }); + project = applyCanvasProjectMutation(project, { + layer: { + blendMode: 'normal', + id: 'pending-layer', + isEnabled: true, + isLocked: false, + name: 'Pending layer', + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + type: 'addCanvasLayer', + }); + const listeners = new Set<() => void>(); + const mutationPort = { + dispatch: (mutation: Parameters[1]) => { + const before = project.canvas; + project = applyCanvasProjectMutation(project, mutation); + for (const listener of listeners) { + listener(); + } + return project.canvas !== before; + }, + getCanvasState: () => project.canvas, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + const uploaded = createDeferred<{ height: number; imageName: string; width: number }>(); + const uploadImage = vi.fn(() => uploaded.promise); + const surface = createTestStubRasterBackend().createSurface(8, 8); + const bitmapStore = createBitmapStore({ + debounceMs: 60_000, + dispatch: mutationPort.dispatch, + encodeSurface: () => Promise.resolve(new Blob(['pending-pixels'], { type: 'image/png' })), + getLayerSource: () => { + const layer = project.canvas.document.layers.find((candidate) => candidate.id === 'pending-layer'); + return layer?.type === 'raster' && layer.source.type === 'paint' ? layer.source : null; + }, + getLayerSurface: () => ({ offset: { x: 0, y: 0 }, surface }), + hashBlob: () => Promise.resolve('pending-pixels'), + uploadImage, + }); + const deps: EngineDeps = { + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + mutationPort, + reportError: vi.fn(), + }; + const engine = registry.getOrCreateEngine(project.id, deps); + bitmapStore.markLayerDirty('pending-layer'); + + registry.releaseEngine(project.id); + await drainUntil(() => uploadImage.mock.calls.length === 1); + expect(fakeTimers.pending()).toBe(1); + + const reacquired = registry.getOrCreateEngine(project.id, deps); + expect(reacquired).toBe(engine); + expect(fakeTimers.pending()).toBe(0); + + uploaded.resolve({ height: 8, imageName: 'persisted-after-reacquire.png', width: 8 }); + await bitmapStore.flushPendingUploads(); + + const layer = project.canvas.document.layers.find((candidate) => candidate.id === 'pending-layer'); + expect(layer?.type === 'raster' && layer.source.type === 'paint' ? layer.source.bitmap?.imageName : null).toBe( + 'persisted-after-reacquire.png' + ); + engine.lifecycle.dispose(); + }); + + it('only schedules disposal when the last reference is released', async () => { + const fakeTimers = createFakeTimers(); + const registry = createEngineRegistry({ timers: fakeTimers.timers }); + const deps = createFakeDeps(); + + registry.getOrCreateEngine('p1', deps); + registry.getOrCreateEngine('p1', deps); + + registry.releaseEngine('p1'); + expect(fakeTimers.pending()).toBe(0); + + registry.releaseEngine('p1'); + expect(fakeTimers.pending()).toBe(1); + + fakeTimers.flush(); + await Promise.resolve(); + await Promise.resolve(); + expect(registry.getEngine('p1')).toBeUndefined(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/engineRegistry.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/engineRegistry.ts new file mode 100644 index 00000000000..1e714fc0c0c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/engineRegistry.ts @@ -0,0 +1,132 @@ +/** + * The engine registry: one {@link CanvasEngine} per project, shared by the + * canvas and layers widgets (and any other surface that needs the same live + * document/pixels). + * + * Acquisition is reference-counted: `getOrCreateEngine` hands out (or creates) + * the instance and bumps the count; `releaseEngine` drops it. When the count + * reaches zero the engine is not disposed immediately — a grace-period timer + * runs first, so a quick unmount/remount (route change, widget re-layout) + * re-acquires the same warm instance instead of paying to rebuild it. A + * re-acquire cancels the pending disposal. The timer is injectable so tests can + * drive it deterministically. + * + * Zero React, zero import-time side effects. + */ + +import { + createCanvasEngine, + type CanvasEngine, + type CanvasEngineOptions, +} from '@workbench/canvas-operations/createCanvasEngine'; + +/** Engine creation dependencies, minus the project id the registry supplies. */ +export type EngineDeps = Omit; + +/** Default grace period before a released engine is disposed (30s). */ +export const DEFAULT_GRACE_PERIOD_MS = 30_000; + +/** Injectable timer seam (defaults to the global timers). */ +export interface RegistryTimers { + setTimeout(handler: () => void, ms: number): number; + clearTimeout(handle: number): void; +} + +/** The registry handle. */ +export interface EngineRegistry { + /** Returns the engine for `projectId`, creating it if needed, and adds a reference. */ + getOrCreateEngine(projectId: string, deps: EngineDeps): CanvasEngine; + /** Returns the engine for `projectId` without changing its reference count. */ + getEngine(projectId: string): CanvasEngine | undefined; + /** Drops a reference; schedules grace-period disposal when the last reference is released. */ + releaseEngine(projectId: string): void; +} + +interface RegistryEntry { + engine: CanvasEngine; + refCount: number; + disposeHandle: number | null; + generation: number; + cooldown: Promise<'cooled' | 'dirty'> | null; +} + +const defaultTimers: RegistryTimers = { + clearTimeout: (handle) => globalThis.clearTimeout(handle), + setTimeout: (handler, ms) => globalThis.setTimeout(handler, ms), +}; + +/** Creates an engine registry with an optional grace period and injectable timers. */ +export const createEngineRegistry = ( + options: { + gracePeriodMs?: number; + timers?: RegistryTimers; + } = {} +): EngineRegistry => { + const gracePeriodMs = options.gracePeriodMs ?? DEFAULT_GRACE_PERIOD_MS; + const timers = options.timers ?? defaultTimers; + const entries = new Map(); + + const cancelDisposal = (entry: RegistryEntry): void => { + if (entry.disposeHandle !== null) { + timers.clearTimeout(entry.disposeHandle); + entry.disposeHandle = null; + } + }; + + const scheduleDisposal = (projectId: string, entry: RegistryEntry, generation: number): void => { + entry.disposeHandle = timers.setTimeout(() => { + entry.disposeHandle = null; + void entry.cooldown?.then((result) => { + if (entry.refCount !== 0 || entry.generation !== generation || entries.get(projectId) !== entry) { + return; + } + if (result === 'dirty') { + entry.cooldown = entry.engine.lifecycle.beginCooldown(); + scheduleDisposal(projectId, entry, generation); + return; + } + entries.delete(projectId); + entry.engine.lifecycle.dispose(); + }); + }, gracePeriodMs); + }; + + return { + getEngine: (projectId) => entries.get(projectId)?.engine, + getOrCreateEngine: (projectId, deps) => { + const existing = entries.get(projectId); + if (existing) { + cancelDisposal(existing); + existing.generation += 1; + existing.refCount += 1; + existing.engine.lifecycle.activate(); + existing.cooldown = null; + return existing.engine; + } + const engine = createCanvasEngine({ projectId, ...deps }); + entries.set(projectId, { cooldown: null, disposeHandle: null, engine, generation: 0, refCount: 1 }); + return engine; + }, + releaseEngine: (projectId) => { + const entry = entries.get(projectId); + if (!entry) { + return; + } + entry.refCount = Math.max(0, entry.refCount - 1); + if (entry.refCount > 0 || entry.disposeHandle !== null) { + return; + } + entry.generation += 1; + const generation = entry.generation; + entry.cooldown = entry.engine.lifecycle.beginCooldown(); + scheduleDisposal(projectId, entry, generation); + }, + }; +}; + +/** The process-wide default registry shared by all widget surfaces. */ +const defaultRegistry = createEngineRegistry(); + +export const getOrCreateEngine = defaultRegistry.getOrCreateEngine; +export const getEngine = defaultRegistry.getEngine; +export const releaseEngine = defaultRegistry.releaseEngine; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.test.ts new file mode 100644 index 00000000000..0286869867a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.test.ts @@ -0,0 +1,419 @@ +import type { ExportLayerPixelsResult, LayerExportGuard } from '@workbench/canvas-engine/engine'; +import type { CanvasRasterLayerContractV2 } from '@workbench/types'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { createCanvasOperationController } from '@workbench/canvas-operations/operationController'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { FilterOperationSessionDeps } from './filterOperationSession'; + +import { createFilterOperationSession, FILTER_AUTO_PROCESS_DEBOUNCE_MS } from './filterOperationSession'; + +const layer: CanvasRasterLayerContractV2 = { + blendMode: 'normal', + filter: { settings: { radius: 2 }, type: 'canny_edge_detection' }, + id: 'layer-1', + isEnabled: true, + isLocked: false, + name: 'Layer', + opacity: 1, + source: { image: { height: 10, imageName: 'source', width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}; +const guard: LayerExportGuard = { + cacheVersion: 1, + documentGeneration: 1, + layer, + layerId: layer.id, + projectId: 'project-1', +}; +const surface = createTestStubRasterBackend().createSurface(10, 10); +const exported: ExportLayerPixelsResult = { + guard, + release: vi.fn(), + rect: { height: 10, width: 10, x: 3, y: 4 }, + status: 'ok', + surface, +}; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +const createDeps = (overrides: Partial = {}): FilterOperationSessionDeps => ({ + canCommit: vi.fn(() => true), + clearPreview: vi.fn(), + commit: vi.fn(() => Promise.resolve({ layerId: layer.id, status: 'committed' as const })), + controller: createCanvasOperationController({ isGuardCurrent: () => true }), + exportPixels: vi.fn(() => Promise.resolve(exported)), + isDraftValid: vi.fn(() => true), + isGuardCurrent: vi.fn(() => true), + makeDurable: vi.fn(() => Promise.resolve()), + publishPreview: vi.fn(() => Promise.resolve('shown' as const)), + runFilter: vi.fn(() => Promise.resolve({ height: 10, imageName: 'filtered', origin: { x: 3, y: 4 }, width: 10 })), + ...overrides, +}); + +describe('createFilterOperationSession', () => { + it('starts with a cloned persisted settings snapshot and an independent local draft', () => { + const deps = createDeps(); + const session = createFilterOperationSession({ + deps, + guard, + initialFilter: layer.filter!, + layerName: 'Portrait', + layerType: 'raster', + }); + + expect(session).not.toBeNull(); + expect(session!.getSnapshot()).toMatchObject({ + autoProcess: true, + draft: { settings: { radius: 2 }, type: 'canny_edge_detection' }, + initialFilter: { settings: { radius: 2 }, type: 'canny_edge_detection' }, + layerId: layer.id, + layerName: 'Portrait', + layerType: 'raster', + status: 'ready', + }); + + session!.updateDraft({ settings: { radius: 9 }, type: 'content_shuffle' }); + expect(session!.getSnapshot().draft).toEqual({ settings: { radius: 9 }, type: 'content_shuffle' }); + expect(session!.getSnapshot().initialFilter).toEqual(layer.filter); + expect(session!.getSnapshot().layerName).toBe('Portrait'); + session!.dispose(); + }); + + it('publishes only the newest guarded process result', async () => { + const older = deferred<{ height: number; imageName: string; origin: { x: number; y: number }; width: number }>(); + const newer = deferred<{ height: number; imageName: string; origin: { x: number; y: number }; width: number }>(); + const runs = [older, newer]; + const deps = createDeps({ runFilter: vi.fn(() => runs.shift()!.promise) }); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + + const first = session.process(); + await vi.waitFor(() => expect(deps.runFilter).toHaveBeenCalledTimes(1)); + const second = session.process(); + await vi.waitFor(() => expect(deps.runFilter).toHaveBeenCalledTimes(2)); + newer.resolve({ height: 10, imageName: 'newer', origin: { x: 3, y: 4 }, width: 10 }); + await second; + older.resolve({ height: 10, imageName: 'older', origin: { x: 3, y: 4 }, width: 10 }); + await first; + + expect(deps.publishPreview).toHaveBeenCalledOnce(); + expect(deps.publishPreview).toHaveBeenCalledWith( + 'newer', + { height: 10, width: 10, x: 3, y: 4 }, + guard, + 'canny_edge_detection' + ); + expect(session.getSnapshot().preview?.imageName).toBe('newer'); + }); + + it('publishes and commits the filter output rect instead of the exported source rect', async () => { + const deps = createDeps({ + runFilter: vi.fn(() => + Promise.resolve({ height: 22, imageName: 'blurred', origin: { x: -3, y: -2 }, width: 24 }) + ), + }); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + + await session.process(); + expect(session.getSnapshot().preview?.rect).toEqual({ height: 22, width: 24, x: -3, y: -2 }); + await session.commit('apply'); + + expect(deps.commit).toHaveBeenCalledWith( + expect.objectContaining({ + image: { height: 22, imageName: 'blurred', width: 24 }, + rect: { height: 22, width: 24, x: -3, y: -2 }, + }) + ); + }); + + it('reset selects current filter defaults, clears preview, and keeps the operation active', async () => { + const deps = createDeps(); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + await session.process(); + + session.reset({ high_threshold: 200, low_threshold: 100 }); + + expect(session.getSnapshot()).toMatchObject({ + draft: { settings: { high_threshold: 200, low_threshold: 100 }, type: 'canny_edge_detection' }, + error: null, + preview: null, + status: 'ready', + }); + expect(deps.clearPreview).toHaveBeenCalled(); + expect(deps.controller.getSnapshot()).toMatchObject({ identity: { kind: 'filter' }, status: 'active' }); + }); + + it('keeps the preview and session retryable when durability fails', async () => { + const deps = createDeps({ makeDurable: vi.fn(() => Promise.reject(new Error('promotion failed'))) }); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + await session.process(); + + await session.commit('apply'); + + expect(deps.commit).not.toHaveBeenCalled(); + expect(session.getSnapshot()).toMatchObject({ + error: 'promotion failed', + preview: { imageName: 'filtered' }, + status: 'error', + }); + expect(deps.controller.getSnapshot()).toMatchObject({ identity: { kind: 'filter' }, status: 'active' }); + }); + + it.each(['apply', 'raster', 'control'] as const)( + 'promotes, commits to %s, and leaves the operation for its owner to end', + async (target) => { + const calls: string[] = []; + const deps = createDeps({ + commit: vi.fn((options) => { + calls.push(`commit:${options.target}`); + return Promise.resolve({ layerId: layer.id, status: 'committed' as const }); + }), + makeDurable: vi.fn(() => { + calls.push('durable'); + return Promise.resolve(); + }), + }); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + await session.process(); + + await session.commit(target); + + expect(calls).toEqual(['durable', `commit:${target}`]); + expect(deps.commit).toHaveBeenCalledWith( + expect.objectContaining({ + draft: layer.filter, + guard, + target, + }) + ); + expect(session.getSnapshot()).toMatchObject({ error: null, preview: null, status: 'ready' }); + expect(deps.controller.getSnapshot()).toMatchObject({ identity: { kind: 'filter' }, status: 'active' }); + session.dispose(); + expect(deps.controller.getSnapshot()).toEqual({ status: 'idle' }); + } + ); + + it('retains the guarded preview after a failed commit so Apply can retry', async () => { + const deps = createDeps({ + commit: vi.fn(() => Promise.resolve({ message: 'cache failed', status: 'failed' as const })), + }); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + await session.process(); + + await session.commit('apply'); + + expect(session.getSnapshot()).toMatchObject({ + error: 'cache failed', + preview: { imageName: 'filtered' }, + status: 'error', + }); + expect(deps.controller.getSnapshot()).toMatchObject({ identity: { kind: 'filter' }, status: 'active' }); + }); + + it('cancel aborts work, clears preview, and closes the operation independently of subscribers', async () => { + const result = deferred<{ height: number; imageName: string; origin: { x: number; y: number }; width: number }>(); + let signal: AbortSignal | undefined; + const deps = createDeps({ + runFilter: vi.fn((options) => { + signal = options.signal; + return result.promise; + }), + }); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + const unsubscribe = session.subscribe(vi.fn()); + unsubscribe(); + const pending = session.process(); + await vi.waitFor(() => expect(signal).toBeDefined()); + + session.cancel(); + result.resolve({ height: 10, imageName: 'late', origin: { x: 3, y: 4 }, width: 10 }); + await pending; + + expect(signal?.aborted).toBe(true); + expect(deps.publishPreview).not.toHaveBeenCalled(); + expect(deps.controller.getSnapshot()).toEqual({ status: 'idle' }); + }); + + it.each(['export', 'graph'] as const)( + 'interrupts processing at the %s boundary while preserving the draft and active operation', + async (boundary) => { + const wait = deferred(); + const deps = createDeps({ + exportPixels: + boundary === 'export' + ? vi.fn(() => wait.promise as Promise) + : vi.fn(() => Promise.resolve(exported)), + runFilter: + boundary === 'graph' + ? vi.fn(() => wait.promise as ReturnType) + : vi.fn(() => Promise.resolve({ height: 10, imageName: 'filtered', origin: { x: 3, y: 4 }, width: 10 })), + }); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + session.updateDraft({ settings: { radius: 17 }, type: 'img_blur' }); + const pending = session.process(); + await vi.waitFor(() => expect(boundary === 'export' ? deps.exportPixels : deps.runFilter).toHaveBeenCalledOnce()); + + session.interruptProcessing(); + if (boundary === 'export') { + wait.resolve(exported); + } else { + wait.resolve({ height: 10, imageName: 'late', origin: { x: 3, y: 4 }, width: 10 }); + } + await pending; + + expect(deps.publishPreview).not.toHaveBeenCalled(); + expect(session.getSnapshot()).toMatchObject({ + draft: { settings: { radius: 17 }, type: 'img_blur' }, + preview: null, + status: 'ready', + }); + expect(deps.controller.getSnapshot()).toMatchObject({ phase: 'ready', status: 'active' }); + } + ); +}); + +describe('auto-process', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + const flushScheduledRun = async (deps: FilterOperationSessionDeps, times: number) => { + vi.useRealTimers(); + await vi.waitFor(() => expect(deps.runFilter).toHaveBeenCalledTimes(times)); + }; + + it('debounces draft updates into a single process run with the latest settings', async () => { + vi.useFakeTimers(); + const deps = createDeps(); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + + session.updateDraft({ settings: { radius: 4 }, type: 'img_blur' }); + await vi.advanceTimersByTimeAsync(FILTER_AUTO_PROCESS_DEBOUNCE_MS - 100); + expect(deps.runFilter).not.toHaveBeenCalled(); + session.updateDraft({ settings: { radius: 6 }, type: 'img_blur' }); + await vi.advanceTimersByTimeAsync(FILTER_AUTO_PROCESS_DEBOUNCE_MS); + await flushScheduledRun(deps, 1); + expect(deps.runFilter).toHaveBeenCalledWith( + expect.objectContaining({ filterType: 'img_blur', settings: { radius: 6 } }) + ); + await vi.waitFor(() => expect(session.getSnapshot().preview?.imageName).toBe('filtered')); + session.dispose(); + }); + + it('never schedules for an invalid draft', async () => { + vi.useFakeTimers(); + const deps = createDeps({ isDraftValid: vi.fn(() => false) }); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + + session.updateDraft({ settings: { model: null }, type: 'spandrel_filter' }); + await vi.advanceTimersByTimeAsync(FILTER_AUTO_PROCESS_DEBOUNCE_MS * 3); + expect(deps.runFilter).not.toHaveBeenCalled(); + session.dispose(); + }); + + it('setAutoProcess(false) cancels the pending run and blocks future scheduling', async () => { + vi.useFakeTimers(); + const deps = createDeps(); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + + session.updateDraft({ settings: { radius: 4 }, type: 'img_blur' }); + session.setAutoProcess(false); + expect(session.getSnapshot().autoProcess).toBe(false); + session.updateDraft({ settings: { radius: 6 }, type: 'img_blur' }); + await vi.advanceTimersByTimeAsync(FILTER_AUTO_PROCESS_DEBOUNCE_MS * 3); + expect(deps.runFilter).not.toHaveBeenCalled(); + session.dispose(); + }); + + it('setAutoProcess(true) schedules a run when no preview exists yet', async () => { + vi.useFakeTimers(); + const deps = createDeps(); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + + session.setAutoProcess(false); + session.setAutoProcess(true); + await vi.advanceTimersByTimeAsync(FILTER_AUTO_PROCESS_DEBOUNCE_MS); + await flushScheduledRun(deps, 1); + session.dispose(); + }); + + it('cancel clears the pending auto-run', async () => { + vi.useFakeTimers(); + const deps = createDeps(); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + + session.updateDraft({ settings: { radius: 4 }, type: 'img_blur' }); + session.cancel(); + await vi.advanceTimersByTimeAsync(FILTER_AUTO_PROCESS_DEBOUNCE_MS * 3); + expect(deps.runFilter).not.toHaveBeenCalled(); + session.dispose(); + }); + + it('a manual process supersedes the pending debounced run', async () => { + vi.useFakeTimers(); + const deps = createDeps(); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + + session.updateDraft({ settings: { radius: 4 }, type: 'img_blur' }); + await session.process(); + expect(deps.runFilter).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(FILTER_AUTO_PROCESS_DEBOUNCE_MS * 3); + expect(deps.runFilter).toHaveBeenCalledTimes(1); + session.dispose(); + }); + + it('interruptProcessing clears the pending auto-run', async () => { + vi.useFakeTimers(); + const deps = createDeps(); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + + session.updateDraft({ settings: { radius: 4 }, type: 'img_blur' }); + expect(session.getSnapshot().status).toBe('ready'); + session.interruptProcessing(); + await vi.advanceTimersByTimeAsync(FILTER_AUTO_PROCESS_DEBOUNCE_MS * 3); + expect(deps.runFilter).not.toHaveBeenCalled(); + session.dispose(); + }); + + it('setAutoProcess(true) does not reschedule when a preview exists', async () => { + vi.useFakeTimers(); + const deps = createDeps(); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + + session.updateDraft({ settings: { radius: 4 }, type: 'img_blur' }); + await vi.advanceTimersByTimeAsync(FILTER_AUTO_PROCESS_DEBOUNCE_MS); + await flushScheduledRun(deps, 1); + await vi.waitFor(() => expect(session.getSnapshot().preview?.imageName).toBe('filtered')); + + session.setAutoProcess(false); + session.setAutoProcess(true); + await new Promise((resolve) => { + setTimeout(resolve, FILTER_AUTO_PROCESS_DEBOUNCE_MS * 3); + }); + expect(deps.runFilter).toHaveBeenCalledTimes(1); + session.dispose(); + }); + + it('setAutoProcess(true) does not schedule while a run is in flight', async () => { + vi.useFakeTimers(); + const deps = createDeps(); + const session = createFilterOperationSession({ deps, guard, initialFilter: layer.filter!, layerType: 'raster' })!; + + const inFlight = session.process(); + session.setAutoProcess(false); + session.setAutoProcess(true); + await vi.advanceTimersByTimeAsync(FILTER_AUTO_PROCESS_DEBOUNCE_MS * 3); + vi.useRealTimers(); + await inFlight; + expect(deps.runFilter).toHaveBeenCalledTimes(1); + session.dispose(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.ts new file mode 100644 index 00000000000..b948aec6d2a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.ts @@ -0,0 +1,385 @@ +import type { + CommitRasterFilterResult, + ExportLayerPixelsResult, + LayerExportGuard, +} from '@workbench/canvas-engine/engine'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { + FilterCommitTarget, + FilterOperationPreview, + FilterOperationSessionState, + LayerFilterSettings, +} from '@workbench/canvas-operations/operationTypes'; +export type { + FilterCommitTarget, + FilterOperationPreview, + FilterOperationSessionState, + LayerFilterSettings, +} from '@workbench/canvas-operations/operationTypes'; +import type { + CanvasOperationController, + CanvasOperationRunResult, + CanvasOperationSession, +} from '@workbench/canvas-operations/operationController'; + +import type { LayerFilterResult } from './layerFilterRunner'; + +/** Delay between the last draft edit and the automatic preview run. */ +export const FILTER_AUTO_PROCESS_DEBOUNCE_MS = 400; + +export interface FilterOperationSessionDeps { + controller: CanvasOperationController; + exportPixels(): Promise; + runFilter(options: { + filterType: string; + input: { surface: RasterSurface; rect: Rect }; + settings: Record; + signal: AbortSignal; + }): Promise; + publishPreview( + imageName: string, + rect: Rect, + guard: LayerExportGuard, + filterType: string + ): Promise<'shown' | 'missing' | 'stale'>; + clearPreview(): void; + isGuardCurrent(guard: LayerExportGuard): boolean; + isDraftValid(draft: LayerFilterSettings): boolean; + makeDurable(imageName: string): Promise; + canCommit(): boolean; + commit(options: { + draft: LayerFilterSettings; + guard: LayerExportGuard; + image: { imageName: string; width: number; height: number }; + origin: { x: number; y: number }; + rect: Rect; + signal: AbortSignal; + target: FilterCommitTarget; + }): Promise; +} + +export interface FilterOperationSession { + getSnapshot(): FilterOperationSessionState; + subscribe(listener: () => void): () => void; + updateDraft(draft: LayerFilterSettings): void; + setAutoProcess(value: boolean): void; + process(): Promise; + interruptProcessing(): void; + reset(settings: Record): void; + /** On 'committed' the canvas operation stays active; the owner must dispose the session to end it. */ + commit(target: FilterCommitTarget): Promise<'committed' | 'blocked' | 'stale'>; + blockCommit(): void; + cancel(): void; + dispose(): void; +} + +export interface CreateFilterOperationSessionOptions { + deps: FilterOperationSessionDeps; + guard: LayerExportGuard; + initialFilter: LayerFilterSettings | null; + initialDraft?: LayerFilterSettings; + layerName?: string; + layerType: 'raster' | 'control'; +} + +const cloneFilter = (filter: LayerFilterSettings | null): LayerFilterSettings | null => + filter ? structuredClone(filter) : null; + +const sameGuard = (left: LayerExportGuard, right: LayerExportGuard): boolean => + left.projectId === right.projectId && + left.layerId === right.layerId && + left.layer === right.layer && + left.cacheVersion === right.cacheVersion && + left.documentGeneration === right.documentGeneration; + +const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +export const createFilterOperationSession = ( + options: CreateFilterOperationSessionOptions +): FilterOperationSession | null => { + const { deps, guard, layerType } = options; + const initialFilter = cloneFilter(options.initialFilter); + const fallback = options.initialDraft ?? { settings: {}, type: 'canny_edge_detection' }; + let state: FilterOperationSessionState = { + autoProcess: true, + draft: cloneFilter(initialFilter) ?? fallback, + error: null, + initialFilter, + layerId: guard.layerId, + layerName: options.layerName ?? guard.layer.name, + layerType, + preview: null, + status: 'ready', + }; + let disposed = false; + let commitController: AbortController | null = null; + let commitToken = 0; + const listeners = new Set<() => void>(); + let autoProcessTimer: ReturnType | null = null; + const clearAutoProcess = (): void => { + if (autoProcessTimer !== null) { + clearTimeout(autoProcessTimer); + autoProcessTimer = null; + } + }; + const scheduleAutoProcess = (): void => { + clearAutoProcess(); + if (!state.autoProcess || !deps.isDraftValid(state.draft)) { + return; + } + autoProcessTimer = setTimeout(() => { + autoProcessTimer = null; + void process(); + }, FILTER_AUTO_PROCESS_DEBOUNCE_MS); + }; + + const publish = (next: FilterOperationSessionState): void => { + if (disposed) { + return; + } + state = next; + for (const listener of listeners) { + try { + listener(); + } catch { + // Session transitions must not be interrupted by a faulty view subscriber. + } + } + }; + const cleanupPreview = (): void => { + deps.clearPreview(); + if (state.preview) { + publish({ ...state, preview: null }); + } + }; + const operation: CanvasOperationSession | null = deps.controller.start({ + cleanupPreview, + guard, + identity: { kind: 'filter', layerId: guard.layerId, projectId: guard.projectId }, + }); + if (!operation) { + return null; + } + + const process = async (): Promise => { + if (disposed) { + return 'stale'; + } + clearAutoProcess(); + const requestDraft = structuredClone(state.draft); + publish({ ...state, error: null, preview: null, status: 'processing' }); + const result = await operation.run( + async (signal) => { + const exported = await deps.exportPixels(); + if (exported.status !== 'ok') { + throw new Error(`The filter source is ${exported.status}.`); + } + try { + if (!sameGuard(exported.guard, guard) || !deps.isGuardCurrent(guard)) { + throw new DOMException('The filter source became stale.', 'AbortError'); + } + const filtered = await deps.runFilter({ + filterType: requestDraft.type, + input: { rect: exported.rect, surface: exported.surface }, + settings: requestDraft.settings, + signal, + }); + if (signal.aborted || !deps.isGuardCurrent(guard)) { + throw new DOMException('The filter request was superseded.', 'AbortError'); + } + const rect = { height: filtered.height, width: filtered.width, ...filtered.origin }; + const shown = await deps.publishPreview(filtered.imageName, rect, guard, requestDraft.type); + if (signal.aborted || !deps.isGuardCurrent(guard)) { + throw new DOMException('The filter request was superseded.', 'AbortError'); + } + if (shown !== 'shown') { + throw new DOMException('The filter source became stale.', 'AbortError'); + } + return { + guard, + height: filtered.height, + imageName: filtered.imageName, + origin: filtered.origin, + rect, + width: filtered.width, + } satisfies FilterOperationPreview; + } finally { + exported.release(); + } + }, + (preview) => { + publish({ ...state, error: null, preview, status: 'ready' }); + return undefined; + } + ); + if (result === 'error') { + const operationState = deps.controller.getSnapshot(); + publish({ + ...state, + error: operationState.status === 'active' ? operationState.error : 'The filter failed.', + preview: null, + status: 'error', + }); + } + return result; + }; + + const cancelCommit = (): void => { + commitToken += 1; + commitController?.abort(); + commitController = null; + }; + + const commit = async (target: FilterCommitTarget): Promise<'committed' | 'blocked' | 'stale'> => { + const preview = state.preview; + if (disposed || !preview || state.status === 'processing' || state.status === 'committing') { + return 'stale'; + } + if (!deps.canCommit()) { + return 'blocked'; + } + clearAutoProcess(); + cancelCommit(); + const token = commitToken; + const controller = new AbortController(); + commitController = controller; + publish({ ...state, error: null, status: 'committing' }); + try { + if (!deps.canCommit()) { + publish({ ...state, error: null, status: 'ready' }); + return 'blocked'; + } + await deps.makeDurable(preview.imageName); + if (!deps.canCommit()) { + if (!disposed && token === commitToken) { + publish({ ...state, error: null, status: 'ready' }); + } + return 'blocked'; + } + if (disposed || token !== commitToken || controller.signal.aborted || !deps.isGuardCurrent(guard)) { + return 'stale'; + } + const result = await deps.commit({ + draft: structuredClone(state.draft), + guard, + image: { height: preview.height, imageName: preview.imageName, width: preview.width }, + origin: preview.origin, + rect: preview.rect, + signal: controller.signal, + target, + }); + if (result.status === 'committed') { + // The operation stays active: the owner ends it by disposing the + // session, keeping teardown out of this call stack. + publish({ ...state, error: null, preview: null, status: 'ready' }); + return 'committed'; + } + if (disposed || token !== commitToken || controller.signal.aborted) { + return 'stale'; + } + publish({ + ...state, + error: result.status === 'failed' ? result.message : `Filter commit is ${result.status}.`, + status: 'error', + }); + return result.status === 'locked' ? 'blocked' : 'stale'; + } catch (error) { + if (!disposed && token === commitToken && !controller.signal.aborted) { + publish({ ...state, error: message(error), status: 'error' }); + } + return 'stale'; + } finally { + if (token === commitToken) { + commitController = null; + } + } + }; + + const cancel = (): void => { + if (disposed) { + return; + } + clearAutoProcess(); + cancelCommit(); + operation.cancel(); + publish({ ...state, error: null, preview: null, status: 'ready' }); + }; + + return { + blockCommit: () => { + if (commitController) { + cancelCommit(); + publish({ ...state, error: null, status: 'ready' }); + } + }, + cancel, + commit, + dispose: () => { + if (disposed) { + return; + } + clearAutoProcess(); + cancelCommit(); + operation.cancel(); + disposed = true; + listeners.clear(); + }, + getSnapshot: () => state, + interruptProcessing: () => { + if (disposed) { + return; + } + clearAutoProcess(); + if (state.status !== 'processing') { + return; + } + operation.interruptProcessing(); + publish({ ...state, error: null, preview: null, status: 'ready' }); + }, + process, + reset: (settings) => { + if (disposed) { + return; + } + cancelCommit(); + operation.reset(); + publish({ + ...state, + draft: { settings: structuredClone(settings), type: state.draft.type }, + error: null, + preview: null, + status: 'ready', + }); + scheduleAutoProcess(); + }, + setAutoProcess: (value) => { + if (disposed || state.autoProcess === value) { + return; + } + publish({ ...state, autoProcess: value }); + if (!value) { + clearAutoProcess(); + return; + } + if (!state.preview && state.status !== 'processing' && state.status !== 'committing') { + scheduleAutoProcess(); + } + }, + subscribe: (listener) => { + if (!disposed) { + listeners.add(listener); + } + return () => listeners.delete(listener); + }, + updateDraft: (draft) => { + if (disposed) { + return; + } + cancelCommit(); + operation.reset(); + publish({ ...state, draft: structuredClone(draft), error: null, preview: null, status: 'ready' }); + scheduleAutoProcess(); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/fullEngineImportAudit.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/fullEngineImportAudit.ts new file mode 100644 index 00000000000..47e438d7e86 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/fullEngineImportAudit.ts @@ -0,0 +1,37 @@ +const FULL_ENGINE_MODULE_PATTERN = String.raw`(?:@workbench/canvas-engine/engine|@workbench/canvas-operations/createCanvasEngine)`; +const FULL_ENGINE_MODULE = new RegExp(String.raw`['"]${FULL_ENGINE_MODULE_PATTERN}['"]`); + +/** Detects every supported syntax that exposes or references the full CanvasEngine type. */ +export const referencesFullCanvasEngine = (source: string): boolean => { + const namedReference = new RegExp( + String.raw`(?:import|export)(?:\s+type)?\s*\{[^}]*\bCanvasEngine\b[^}]*\}\s*from\s*['"]${FULL_ENGINE_MODULE_PATTERN}['"]`, + 's' + ); + if (namedReference.test(source)) { + return true; + } + + const starReExport = new RegExp( + String.raw`export\s*\*\s*(?:as\s+[A-Za-z_$][\w$]*\s*)?from\s*['"]${FULL_ENGINE_MODULE_PATTERN}['"]` + ); + if (starReExport.test(source)) { + return true; + } + + const inlineReference = new RegExp( + String.raw`import\s*\(\s*['"]${FULL_ENGINE_MODULE_PATTERN}['"]\s*\)\s*\.\s*CanvasEngine\b` + ); + if (inlineReference.test(source)) { + return true; + } + + const namespaceImport = /import(?:\s+type)?\s*\*\s*as\s*[A-Za-z_$][\w$]*\s*from\s*([^;\n]+)/g; + for (const match of source.matchAll(namespaceImport)) { + const moduleSource = match[1]; + if (moduleSource && FULL_ENGINE_MODULE.test(moduleSource)) { + return true; + } + } + + return false; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/fullEngineImports.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/fullEngineImports.test.ts new file mode 100644 index 00000000000..ae4741a4fef --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/fullEngineImports.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { referencesFullCanvasEngine } from './fullEngineImportAudit'; + +const modules = import.meta.glob('../**/*.{ts,tsx}', { eager: true, import: 'default', query: '?raw' }); + +const ALLOWED = new Set(['./createCanvasEngine.ts', './engineRegistry.ts']); +const fullEngineImports = (): string[] => { + const result: string[] = []; + for (const [file, source] of Object.entries(modules)) { + const relative = file.replace('../', ''); + if ( + relative.endsWith('.test.ts') || + relative.endsWith('.test.tsx') || + ALLOWED.has(relative) || + typeof source !== 'string' + ) { + continue; + } + if (referencesFullCanvasEngine(source)) { + result.push(relative); + } + } + return result.sort(); +}; + +describe('full CanvasEngine consumer boundary', () => { + it.each([ + [`export type { CanvasEngine } from '@workbench/canvas-engine/engine';`, 'named type re-export'], + [`export { CanvasEngine as PublicEngine } from '@workbench/canvas-engine/engine';`, 'aliased re-export'], + [`export * from '@workbench/canvas-engine/engine';`, 'star re-export'], + [`export * as Core from '@workbench/canvas-engine/engine';`, 'namespace re-export'], + [ + `import type * as Core from '@workbench/canvas-engine/engine'; type Consumer = Core.CanvasEngine;`, + 'qualified namespace reference', + ], + [`import * as Core from '@workbench/canvas-engine/engine'; void Core;`, 'namespace import'], + [`type Consumer = import('@workbench/canvas-operations/createCanvasEngine').CanvasEngine;`, 'inline imported type'], + ])('detects $1', (source) => { + expect(referencesFullCanvasEngine(source)).toBe(true); + }); + + it('does not reject a named import of a narrow capability', () => { + expect( + referencesFullCanvasEngine( + `import type { CanvasCoreStores } from '@workbench/canvas-engine/engine'; type Stores = CanvasCoreStores;` + ) + ).toBe(false); + }); + + it('limits the full engine type to registry and composition roots', () => { + expect(fullEngineImports()).toEqual([]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts new file mode 100644 index 00000000000..123c79dfc02 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts @@ -0,0 +1,598 @@ +import type { CanvasEngine } from '@workbench/canvas-engine/engine'; +import type { uploadCanvasImage } from '@workbench/canvas-operations/backend/canvasImages'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { GalleryImage } from '@workbench/gallery/api'; +import type { GenerateWidgetValues } from '@workbench/generation/types'; +import type { CanvasLayerContract, Project, WorkbenchState } from '@workbench/types'; + +import { + createControlLayer, + createEmptyPaintLayer, + createInpaintMaskFromImage, + createRegionalGuidanceFromImage, + createRegionalGuidanceLayerWithRefImage, + DEFAULT_INPAINT_MASK_FILL, + nextRegionalGuidanceFillColor, +} from '@workbench/widgets/layers/layerOps'; +import { getProjectWidgetValues } from '@workbench/widgetState'; +import { createInitialWorkbenchState, type WorkbenchAction } from '@workbench/workbenchState'; +import { describe, expect, it, vi } from 'vitest'; + +import { importGalleryImagesToCanvas, type GalleryCanvasImportDestination } from './importGalleryImages'; + +const image = (imageName: string, width = 320, height = 160): GalleryImage => ({ + boardId: 'none', + height, + imageCategory: 'general', + imageName, + imageUrl: `https://images.example/${imageName}`, + queuedAt: '2026-01-01T00:00:00.000Z', + sourceQueueItemId: 'queue-1', + starred: false, + thumbnailUrl: `https://images.example/thumb/${imageName}`, + width, +}); + +const withProject = (mutate?: (project: Project) => Project): { project: Project; state: WorkbenchState } => { + const state = createInitialWorkbenchState(); + const current = state.projects.find((candidate) => candidate.id === state.activeProjectId)!; + const document = { + ...current.canvas.document, + bbox: { height: 512, width: 768, x: 31, y: 47 }, + layers: [createEmptyPaintLayer('Existing', 'previous')], + selectedLayerId: 'previous', + }; + const base = { ...current, canvas: { ...current.canvas, document } }; + const project = mutate ? mutate(base) : base; + return { project, state: { ...state, projects: [project] } }; +}; + +const setModel = (project: Project, base: GenerateWidgetValues['model']['base']): Project => { + const generate = project.widgetInstances.generate; + if (!generate) { + throw new Error('Expected generate widget'); + } + const previous = getProjectWidgetValues(project, 'generate'); + const model = { base, key: 'test-model', name: 'Test Model', type: 'main' } as GenerateWidgetValues['model']; + const values: GenerateWidgetValues = { + aspectRatioId: '1:1', + aspectRatioIsLocked: false, + aspectRatioValue: 1, + batchCount: 1, + cfgRescaleMultiplier: 0, + cfgScale: 7, + clipEmbedModel: null, + clipGEmbedModel: null, + clipLEmbedModel: null, + clipSkip: 0, + colorCompensation: false, + componentSourceModel: null, + height: 1024, + loras: [], + negativePrompt: '', + negativePromptEnabled: true, + negativePromptHeightPx: 56, + positivePrompt: '', + positivePromptHeightPx: 96, + qwen3EncoderModel: null, + qwenVLEncoderModel: null, + referenceImages: [], + scheduler: 'euler_a', + seamlessXAxis: false, + seamlessYAxis: false, + seed: 1, + shouldRandomizeSeed: true, + steps: 30, + t5EncoderModel: null, + vae: null, + vaePrecision: 'fp32', + width: 1024, + ...previous, + model, + modelKey: model.key, + }; + return { + ...project, + widgetInstances: { + ...project.widgetInstances, + generate: { + ...generate, + state: { ...generate.state, values: values as unknown as Record }, + }, + }, + }; +}; + +const engine = ( + projectId: string, + canCommitStructural: CanvasEngine['layers']['canCommitStructural'] = vi.fn(() => true), + commitStructural: CanvasEngine['layers']['commitStructural'] = vi.fn(() => true) +): CanvasEngine => ({ layers: { canCommitStructural, commitStructural }, projectId }) as unknown as CanvasEngine; + +const getForwardLayers = (action: WorkbenchAction | CanvasProjectMutation): readonly CanvasLayerContract[] => { + const mutation = action.type === 'applyCanvasProjectMutation' ? action.mutation : action; + if (mutation.type !== 'applyCanvasLayerStackMutation' || !mutation.add) { + throw new Error('Expected add stack mutation'); + } + return mutation.add.layers; +}; + +const expectedLayer = ( + destination: Exclude, + source: GalleryImage, + index: number, + bbox: Project['canvas']['document']['bbox'], + base: string | null +): CanvasLayerContract => { + const ref = { height: source.height, imageName: source.imageName, width: source.width }; + switch (destination) { + case 'raster': + return { + blendMode: 'normal', + id: expect.any(String) as string, + isEnabled: true, + isLocked: false, + name: `Layer ${index + 1}`, + opacity: 1, + source: { image: ref, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: bbox.x, y: bbox.y }, + type: 'raster', + }; + case 'control': + return { + ...createControlLayer(`Control Layer ${index + 1}`, 'expected', base), + id: expect.any(String) as string, + source: { image: ref, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: bbox.x, y: bbox.y }, + }; + case 'inpaint-mask': + return createInpaintMaskFromImage({ + fill: DEFAULT_INPAINT_MASK_FILL, + id: expect.any(String) as string, + image: ref, + name: `Inpaint Mask ${index + 1}`, + rect: bbox, + }); + case 'regional-guidance': + return createRegionalGuidanceFromImage({ + fill: { color: nextRegionalGuidanceFillColor(index), style: 'solid' }, + id: expect.any(String) as string, + image: ref, + name: `Regional Guidance ${index + 1}`, + rect: bbox, + }); + case 'regional-reference': { + const layer = createRegionalGuidanceLayerWithRefImage(`Regional Guidance ${index + 1}`, index, base); + const reference = layer.referenceImages[0]!; + return { + ...layer, + id: expect.any(String) as string, + referenceImages: [ + { ...reference, config: { ...reference.config, image: source }, id: expect.any(String) as string }, + ], + }; + } + } +}; + +describe('importGalleryImagesToCanvas', () => { + it.each>([ + 'raster', + 'control', + 'inpaint-mask', + 'regional-guidance', + 'regional-reference', + ])('builds the %s destination contract in input order and commits one history entry', async (destination) => { + const { project, state } = withProject((value) => setModel(value, 'z-image')); + const dispatch = vi.fn<(action: WorkbenchAction) => void>(); + const canvasEngine = engine(project.id); + const images = [image('first.png'), image('second.png', 640, 480)]; + + const result = await importGalleryImagesToCanvas({ + destination, + dispatch, + engine: canvasEngine, + getState: () => state, + images, + project, + }); + + expect(result.status).toBe('imported'); + if (result.status !== 'imported') { + return; + } + expect(result.failedImageNames).toEqual([]); + expect(dispatch).not.toHaveBeenCalled(); + expect(canvasEngine.layers.commitStructural).toHaveBeenCalledOnce(); + const [, forward, inverse] = vi.mocked(canvasEngine.layers.commitStructural).mock.calls[0]!; + const layers = getForwardLayers(forward); + expect(layers).toEqual( + images.map((source, index) => expectedLayer(destination, source, index, project.canvas.document.bbox, 'z-image')) + ); + expect(result.layerIds).toEqual(layers.map((layer) => layer.id)); + expect(forward).toMatchObject({ + add: { index: 0 }, + enabledUpdates: [], + selectedLayerId: layers[1]!.id, + type: 'applyCanvasLayerStackMutation', + }); + expect(inverse).toEqual({ + enabledUpdates: [], + removeIds: layers.map((layer) => layer.id), + selectedLayerId: 'previous', + type: 'applyCanvasLayerStackMutation', + }); + }); + + it('fetches and uploads each resized control once with optimal model dimensions and durable hidden policy', async () => { + const { project, state } = withProject((value) => setModel(value, 'flux2')); + const dispatch = vi.fn<(action: WorkbenchAction) => void>(); + const fetchImage = vi.fn(() => Promise.resolve(new Response(new Blob(['pixels']), { status: 200 }))); + const uploadImage = vi.fn((_blob, options) => + Promise.resolve({ + height: options!.resizeTo!.height, + imageName: `resized-${options!.fileName}`, + width: options!.resizeTo!.width, + }) + ); + + const result = await importGalleryImagesToCanvas({ + destination: 'control-resized', + dispatch, + engine: null, + fetchImage, + getState: () => state, + images: [image('wide.png', 1600, 900), image('square.png', 400, 400)], + project, + uploadImage, + }); + + expect(result.status).toBe('imported'); + expect(fetchImage.mock.calls.map(([url]) => url)).toEqual([ + 'https://images.example/wide.png', + 'https://images.example/square.png', + ]); + expect(uploadImage).toHaveBeenCalledTimes(2); + expect(uploadImage.mock.calls.map(([, options]) => options)).toEqual([ + expect.objectContaining({ + fileName: 'wide.png', + imageCategory: 'other', + isIntermediate: false, + resizeTo: { height: 768, width: 1360 }, + }), + expect.objectContaining({ + fileName: 'square.png', + imageCategory: 'other', + isIntermediate: false, + resizeTo: { height: 1024, width: 1024 }, + }), + ]); + expect(dispatch).toHaveBeenCalledOnce(); + const forward = dispatch.mock.calls[0]![0]; + const layers = getForwardLayers(forward); + expect(layers).toEqual([ + { + ...createControlLayer('Control Layer 1', 'expected', 'flux2'), + id: expect.any(String), + source: { image: { height: 768, imageName: 'resized-wide.png', width: 1360 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 31, y: 47 }, + }, + { + ...createControlLayer('Control Layer 2', 'expected', 'flux2'), + id: expect.any(String), + source: { image: { height: 1024, imageName: 'resized-square.png', width: 1024 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 31, y: 47 }, + }, + ]); + expect( + layers.map((layer) => (layer.type === 'control' && layer.source.type === 'image' ? layer.source.image : null)) + ).toEqual([ + { height: 768, imageName: 'resized-wide.png', width: 1360 }, + { height: 1024, imageName: 'resized-square.png', width: 1024 }, + ]); + expect(forward).toMatchObject({ + mutation: { + selectedLayerId: layers[1]!.id, + type: 'applyCanvasLayerStackMutation', + }, + projectId: project.id, + type: 'applyCanvasProjectMutation', + }); + }); + + it('returns empty without committing', async () => { + const { project, state } = withProject(); + const dispatch = vi.fn<(action: WorkbenchAction) => void>(); + const result = await importGalleryImagesToCanvas({ + destination: 'raster', + dispatch, + engine: null, + getState: () => state, + images: [], + project, + }); + expect(result).toEqual({ status: 'empty' }); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('blocks a locked matching engine before resized preprocessing', async () => { + const { project, state } = withProject(); + const fetchImage = vi.fn(); + const uploadImage = vi.fn(); + const result = await importGalleryImagesToCanvas({ + destination: 'control-resized', + dispatch: vi.fn(), + engine: engine( + project.id, + vi.fn(() => false) + ), + fetchImage, + getState: () => state, + images: [image('a.png')], + project, + uploadImage, + }); + expect(result).toEqual({ status: 'blocked' }); + expect(fetchImage).not.toHaveBeenCalled(); + expect(uploadImage).not.toHaveBeenCalled(); + }); + + it('returns blocked when the matching engine locks during preprocessing', async () => { + const { project, state } = withProject(); + const fetchImage = vi.fn(() => Promise.resolve(new Response(new Blob(['pixels']), { status: 200 }))); + const uploadImage = vi.fn(() => + Promise.resolve({ height: 512, imageName: 'resized', width: 512 }) + ); + const commitStructural = vi.fn< + (_label: string, _forward: CanvasProjectMutation, _inverse: CanvasProjectMutation) => boolean + >(() => false); + const result = await importGalleryImagesToCanvas({ + destination: 'control-resized', + dispatch: vi.fn(), + engine: engine( + project.id, + vi.fn(() => true), + commitStructural + ), + fetchImage, + getState: () => state, + images: [image('a.png')], + project, + uploadImage, + }); + expect(result).toEqual({ status: 'blocked' }); + expect(commitStructural).toHaveBeenCalledOnce(); + const [, forward, inverse] = commitStructural.mock.calls[0]!; + const layers = getForwardLayers(forward); + expect(inverse).toEqual({ + enabledUpdates: [], + removeIds: layers.map((layer) => layer.id), + selectedLayerId: 'previous', + type: 'applyCanvasLayerStackMutation', + }); + }); + + it('returns stale-project when the target is deleted during preprocessing', async () => { + const { project, state } = withProject(); + let current = state; + const uploadImage = vi.fn(() => { + current = { ...current, projects: [] }; + return Promise.resolve({ height: 512, imageName: 'resized', width: 512 }); + }); + const dispatch = vi.fn<(action: WorkbenchAction) => void>(); + const result = await importGalleryImagesToCanvas({ + destination: 'control-resized', + dispatch, + engine: null, + fetchImage: () => Promise.resolve(new Response(new Blob(['pixels']), { status: 200 })), + getState: () => current, + images: [image('a.png')], + project, + uploadImage, + }); + expect(result).toEqual({ status: 'stale-project' }); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches to the captured project when the active project changes during preprocessing', async () => { + const { project, state } = withProject(); + const other = { ...project, id: 'other-project' }; + let current = { ...state, projects: [project, other] }; + const dispatch = vi.fn<(action: WorkbenchAction) => void>(); + const matchingEngine = engine(project.id); + const uploadImage = vi.fn(() => { + current = { ...current, activeProjectId: other.id }; + return Promise.resolve({ height: 512, imageName: 'resized', width: 512 }); + }); + const result = await importGalleryImagesToCanvas({ + destination: 'control-resized', + dispatch, + engine: matchingEngine, + fetchImage: () => Promise.resolve(new Response(new Blob(['pixels']), { status: 200 })), + getState: () => current, + images: [image('a.png')], + project, + uploadImage, + }); + expect(result.status).toBe('imported'); + expect(matchingEngine.layers.commitStructural).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledOnce(); + expect(dispatch.mock.calls[0]![0]).toMatchObject({ projectId: project.id }); + }); + + it('uses the matching engine final guard when the target project becomes active during preprocessing', async () => { + const { project, state } = withProject(); + const other = { ...project, id: 'other-project' }; + let current = { ...state, activeProjectId: other.id, projects: [project, other] }; + const dispatch = vi.fn<(action: WorkbenchAction) => void>(); + const commitStructural = vi.fn(() => false); + const matchingEngine = engine( + project.id, + vi.fn(() => true), + commitStructural + ); + const uploadImage = vi.fn(() => { + current = { ...current, activeProjectId: project.id }; + return Promise.resolve({ height: 512, imageName: 'resized', width: 512 }); + }); + + const result = await importGalleryImagesToCanvas({ + destination: 'control-resized', + dispatch, + engine: matchingEngine, + fetchImage: () => Promise.resolve(new Response(new Blob(['pixels']), { status: 200 })), + getState: () => current, + images: [image('a.png')], + project, + uploadImage, + }); + + expect(result).toEqual({ status: 'blocked' }); + expect(commitStructural).toHaveBeenCalledOnce(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns stale-document when the captured target document identity changes', async () => { + const { project, state } = withProject(); + let current = state; + const uploadImage = vi.fn(() => { + current = { + ...current, + projects: current.projects.map((candidate) => + candidate.id === project.id + ? { ...candidate, canvas: { ...candidate.canvas, document: { ...candidate.canvas.document } } } + : candidate + ), + }; + return Promise.resolve({ height: 512, imageName: 'resized', width: 512 }); + }); + const dispatch = vi.fn<(action: WorkbenchAction) => void>(); + const result = await importGalleryImagesToCanvas({ + destination: 'control-resized', + dispatch, + engine: null, + fetchImage: () => Promise.resolve(new Response(new Blob(['pixels']), { status: 200 })), + getState: () => current, + images: [image('a.png')], + project, + uploadImage, + }); + expect(result).toEqual({ status: 'stale-document' }); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('single-flights imports per project while allowing other projects', async () => { + const first = withProject(); + const secondProject = { ...first.project, id: 'second-project' }; + const secondState = { ...first.state, activeProjectId: secondProject.id, projects: [secondProject] }; + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const fetchImage = vi.fn(async () => { + await pending; + return new Response(new Blob(['pixels']), { status: 200 }); + }); + const uploadImage = vi.fn(() => + Promise.resolve({ height: 512, imageName: 'resized', width: 512 }) + ); + const firstImport = importGalleryImagesToCanvas({ + destination: 'control-resized', + dispatch: vi.fn(), + engine: null, + fetchImage, + getState: () => first.state, + images: [image('a.png')], + project: first.project, + uploadImage, + }); + const overlap = await importGalleryImagesToCanvas({ + destination: 'raster', + dispatch: vi.fn(), + engine: null, + getState: () => first.state, + images: [image('b.png')], + project: first.project, + }); + const independent = await importGalleryImagesToCanvas({ + destination: 'raster', + dispatch: vi.fn(), + engine: null, + getState: () => secondState, + images: [image('c.png')], + project: secondProject, + }); + expect(overlap).toEqual({ status: 'blocked' }); + expect(independent.status).toBe('imported'); + release(); + await firstImport; + }); + + it('bounds resized work at four, preserves successful order, and reports partial failures once', async () => { + const { project, state } = withProject(); + let active = 0; + let maximum = 0; + const releases: Array<() => void> = []; + const fetchImage = vi.fn(async (_input) => { + active += 1; + maximum = Math.max(maximum, active); + await new Promise((resolve) => { + releases.push(resolve); + }); + active -= 1; + return new Response(new Blob(['pixels']), { status: 200 }); + }); + const uploadImage = vi.fn((_blob, options) => { + if (options!.fileName === '2.png' || options!.fileName === '5.png') { + return Promise.reject(new Error('resize failed')); + } + return Promise.resolve({ height: 512, imageName: `ok-${options!.fileName}`, width: 512 }); + }); + const dispatch = vi.fn<(action: WorkbenchAction) => void>(); + const importing = importGalleryImagesToCanvas({ + destination: 'control-resized', + dispatch, + engine: null, + fetchImage, + getState: () => state, + images: Array.from({ length: 7 }, (_, index) => image(`${index}.png`)), + project, + uploadImage, + }); + await vi.waitFor(() => expect(fetchImage).toHaveBeenCalledTimes(4)); + expect(maximum).toBe(4); + releases.splice(0).forEach((resolve) => resolve()); + await vi.waitFor(() => expect(fetchImage).toHaveBeenCalledTimes(7)); + releases.splice(0).forEach((resolve) => resolve()); + const result = await importing; + expect(result.status).toBe('imported'); + if (result.status !== 'imported') { + return; + } + expect(result.failedImageNames).toEqual(['2.png', '5.png']); + expect(dispatch).toHaveBeenCalledOnce(); + expect( + getForwardLayers(dispatch.mock.calls[0]![0]).map((layer) => + layer.type === 'control' && layer.source.type === 'image' ? layer.source.image.imageName : null + ) + ).toEqual(['ok-0.png', 'ok-1.png', 'ok-3.png', 'ok-4.png', 'ok-6.png']); + }); + + it('throws one AggregateError when every resized image fails and releases the project flight', async () => { + const { project, state } = withProject(); + const options = { + destination: 'control-resized' as const, + dispatch: vi.fn(), + engine: null, + fetchImage: () => Promise.resolve(new Response(new Blob(['pixels']), { status: 200 })), + getState: () => state, + images: [image('a.png'), image('b.png')], + project, + uploadImage: vi.fn(() => Promise.reject(new Error('failed'))), + }; + await expect(importGalleryImagesToCanvas(options)).rejects.toBeInstanceOf(AggregateError); + await expect(importGalleryImagesToCanvas({ ...options, destination: 'raster' })).resolves.toMatchObject({ + status: 'imported', + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.ts new file mode 100644 index 00000000000..302eda495ba --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.ts @@ -0,0 +1,300 @@ +import type { CanvasLayerCapability } from '@workbench/canvas-engine/api'; +import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { GalleryImage } from '@workbench/gallery/api'; +import type { CanvasImageRef, CanvasLayerContract, Project, WorkbenchState } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; +import type { Dispatch } from 'react'; + +import { getGenerationDimensions } from '@workbench/generation/baseGenerationPolicies'; +import { calculateNewSize, normalizeGenerateWidgetValues } from '@workbench/generation/settings'; +import { + createControlLayer, + createEmptyPaintLayer, + createInpaintMaskFromImage, + createLayerId, + createRegionalGuidanceFromImage, + createRegionalGuidanceLayerWithRefImage, + DEFAULT_INPAINT_MASK_FILL, + nextControlLayerName, + nextInpaintMaskName, + nextRegionalGuidanceFillColor, + nextRegionalGuidanceName, +} from '@workbench/widgets/layers/layerOps'; +import { getSelectedModelBase } from '@workbench/widgets/layers/selectedModel'; +import { getProjectWidgetValues } from '@workbench/widgetState'; +import { nextLayerName } from '@workbench/workbenchState'; + +import { uploadCanvasImage } from './backend/canvasImages'; + +export type GalleryCanvasImportDestination = + | 'raster' + | 'control' + | 'inpaint-mask' + | 'regional-guidance' + | 'regional-reference' + | 'control-resized'; + +export type ImportGalleryImagesResult = + | { status: 'imported'; layerIds: string[]; failedImageNames: string[] } + | { status: 'blocked' | 'empty' | 'stale-document' | 'stale-project' }; + +interface BuildLayerContext { + bbox: Project['canvas']['document']['bbox']; + existingLayers: readonly CanvasLayerContract[]; + modelBase: string | null; +} + +type LayerImage = CanvasImageRef | GalleryImage; + +const activeImports = new Set(); + +const imageRef = (image: LayerImage): CanvasImageRef => ({ + height: image.height, + imageName: image.imageName, + width: image.width, +}); + +const isGeneratedImage = (image: LayerImage): image is GalleryImage => 'imageUrl' in image; + +const withBboxOrigin = (layer: T, bbox: BuildLayerContext['bbox']): T => ({ + ...layer, + transform: { ...layer.transform, x: bbox.x, y: bbox.y }, +}); + +const buildLayer = ( + image: LayerImage, + destination: GalleryCanvasImportDestination, + context: BuildLayerContext +): CanvasLayerContract => { + const id = createLayerId(); + const names = context.existingLayers.map((layer) => layer.name); + const ref = imageRef(image); + + switch (destination) { + case 'raster': + return withBboxOrigin( + { ...createEmptyPaintLayer(nextLayerName(names), id), source: { image: ref, type: 'image' } }, + context.bbox + ); + case 'control': + case 'control-resized': + return withBboxOrigin( + { + ...createControlLayer(nextControlLayerName(names), id, context.modelBase), + source: { image: ref, type: 'image' }, + }, + context.bbox + ); + case 'inpaint-mask': + return createInpaintMaskFromImage({ + fill: DEFAULT_INPAINT_MASK_FILL, + id, + image: ref, + name: nextInpaintMaskName(names), + rect: context.bbox, + }); + case 'regional-guidance': { + const regionalGuidanceCount = context.existingLayers.filter((layer) => layer.type === 'regional_guidance').length; + return createRegionalGuidanceFromImage({ + fill: { color: nextRegionalGuidanceFillColor(regionalGuidanceCount), style: 'solid' }, + id, + image: ref, + name: nextRegionalGuidanceName(names), + rect: context.bbox, + }); + } + case 'regional-reference': { + const regionalGuidanceCount = context.existingLayers.filter((layer) => layer.type === 'regional_guidance').length; + const layer = createRegionalGuidanceLayerWithRefImage( + nextRegionalGuidanceName(names), + regionalGuidanceCount, + context.modelBase, + id + ); + const referenceImage = layer.referenceImages[0]; + if (!referenceImage) { + throw new Error('Regional reference factory did not create a reference image'); + } + if (!isGeneratedImage(image)) { + throw new Error('Regional reference imports require a gallery image'); + } + return { + ...layer, + referenceImages: [{ ...referenceImage, config: { ...referenceImage.config, image } }], + }; + } + } +}; + +const buildLayers = ( + images: readonly LayerImage[], + destination: GalleryCanvasImportDestination, + project: Project +): CanvasLayerContract[] => { + const layers: CanvasLayerContract[] = []; + const modelBase = getSelectedModelBase(project); + for (const image of images) { + layers.push( + buildLayer(image, destination, { + bbox: project.canvas.document.bbox, + existingLayers: [...project.canvas.document.layers, ...layers], + modelBase, + }) + ); + } + return layers; +}; + +const mapWithConcurrency = async ( + items: readonly T[], + concurrency: number, + mapper: (item: T, index: number) => Promise +): Promise => { + const results: R[] = []; + let nextIndex = 0; + const worker = async (): Promise => { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapper(items[index]!, index); + } + }; + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)); + return results; +}; + +type ResizeResult = + | { status: 'fulfilled'; image: LayerImage } + | { status: 'rejected'; imageName: string; reason: unknown }; + +const resizeImages = async ( + images: readonly GalleryImage[], + project: Project, + fetchImage: typeof fetch, + uploadImage: typeof uploadCanvasImage +): Promise<{ images: LayerImage[]; failedImageNames: string[] }> => { + const generateValues = normalizeGenerateWidgetValues(getProjectWidgetValues(project, 'generate')); + const dimensions = getGenerationDimensions(generateValues?.model); + const results = await mapWithConcurrency(images, 4, async (image): Promise => { + try { + const response = await fetchImage(image.imageUrl); + if (!response.ok) { + throw new Error(`Failed to fetch ${image.imageName}: ${response.status} ${response.statusText}`); + } + const blob = await response.blob(); + const resizeTo = calculateNewSize(image.width / image.height, dimensions.optimal ** 2, dimensions.grid); + const uploaded = await uploadImage(blob, { + fileName: image.imageName, + imageCategory: 'other', + isIntermediate: false, + resizeTo, + }); + return { image: uploaded, status: 'fulfilled' }; + } catch (reason) { + return { imageName: image.imageName, reason, status: 'rejected' }; + } + }); + const successful = results.filter( + (result): result is Extract => result.status === 'fulfilled' + ); + const failed = results.filter( + (result): result is Extract => result.status === 'rejected' + ); + if (successful.length === 0 && failed.length > 0) { + throw new AggregateError( + failed.map((result) => result.reason), + `Failed to resize ${String(failed.length)} gallery image${failed.length === 1 ? '' : 's'}` + ); + } + return { + failedImageNames: failed.map((result) => result.imageName), + images: successful.map((result) => result.image), + }; +}; + +type GalleryImportEngine = { readonly projectId: string; readonly layers: CanvasLayerCapability }; + +export const importGalleryImagesToCanvas = async (options: { + destination: GalleryCanvasImportDestination; + dispatch: Dispatch; + engine: GalleryImportEngine | null; + getState: () => WorkbenchState; + images: readonly GalleryImage[]; + project: Project; + fetchImage?: typeof fetch; + uploadImage?: typeof uploadCanvasImage; +}): Promise => { + const { + destination, + dispatch, + engine, + fetchImage = globalThis.fetch, + getState, + images, + project, + uploadImage = uploadCanvasImage, + } = options; + if (images.length === 0) { + return { status: 'empty' }; + } + if (activeImports.has(project.id)) { + return { status: 'blocked' }; + } + activeImports.add(project.id); + + try { + const capturedDocument = project.canvas.document; + const initialState = getState(); + const matchingProjectEngine = engine !== null && engine.projectId === project.id ? engine : null; + if ( + matchingProjectEngine && + initialState.activeProjectId === project.id && + !matchingProjectEngine.layers.canCommitStructural() + ) { + return { status: 'blocked' }; + } + + let layerImages: readonly LayerImage[] = images; + let failedImageNames: string[] = []; + if (destination === 'control-resized') { + const resized = await resizeImages(images, project, fetchImage, uploadImage); + layerImages = resized.images; + failedImageNames = resized.failedImageNames; + } + + const layers = buildLayers(layerImages, destination, project); + const previousSelectedLayerId = capturedDocument.selectedLayerId; + const forward: CanvasProjectMutation = { + add: { index: 0, layers }, + enabledUpdates: [], + selectedLayerId: layers.at(-1)?.id ?? previousSelectedLayerId, + type: 'applyCanvasLayerStackMutation', + }; + const inverse: CanvasProjectMutation = { + enabledUpdates: [], + removeIds: layers.map((layer) => layer.id), + selectedLayerId: previousSelectedLayerId, + type: 'applyCanvasLayerStackMutation', + }; + + const latestState = getState(); + const latestProject = latestState.projects.find((candidate) => candidate.id === project.id); + if (!latestProject) { + return { status: 'stale-project' }; + } + if (latestProject.canvas.document !== capturedDocument) { + return { status: 'stale-document' }; + } + + if (matchingProjectEngine && latestState.activeProjectId === project.id) { + if (!matchingProjectEngine.layers.commitStructural('Import gallery images', forward, inverse)) { + return { status: 'blocked' }; + } + } else { + dispatch({ mutation: forward, projectId: project.id, type: 'applyCanvasProjectMutation' }); + } + return { failedImageNames, layerIds: layers.map((layer) => layer.id), status: 'imported' }; + } finally { + activeImports.delete(project.id); + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/layerFilterRunner.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/layerFilterRunner.test.ts new file mode 100644 index 00000000000..1f3708a09c9 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/layerFilterRunner.test.ts @@ -0,0 +1,255 @@ +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it } from 'vitest'; + +import { LayerFilterOutputDimensionError, resolveFilterOutputRect, runLayerFilter } from './layerFilterRunner'; + +describe('resolveFilterOutputRect', () => { + const source = { height: 60, width: 80, x: 12, y: 24 }; + + it.each([ + ['gaussian', 2.1, { height: 74, width: 94, x: 5, y: 17 }], + ['box', 2.1, { height: 66, width: 86, x: 9, y: 21 }], + ] as const)('uses exact legacy %s blur padding and actual output dimensions', (blurType, radius, expected) => { + expect( + resolveFilterOutputRect({ + filterType: 'img_blur', + output: { height: expected.height, width: expected.width }, + settings: { blur_type: blurType, radius }, + source, + }) + ).toEqual(expected); + }); + + it('keeps the legacy Spandrel origin while adopting actual upscale dimensions', () => { + expect( + resolveFilterOutputRect({ + filterType: 'spandrel_filter', + output: { height: 240, width: 320 }, + settings: { scale: 4 }, + source, + }) + ).toEqual({ height: 240, width: 320, x: 12, y: 24 }); + }); + + it.each([ + [Number.NaN, 4], + [5_000, 12 - 4_096], + ])('uses the graph-resolved blur radius for malformed or oversized input %s', (radius, expectedX) => { + expect( + resolveFilterOutputRect({ + filterType: 'img_blur', + output: { height: 60, width: 80 }, + settings: { blur_type: 'box', radius }, + source, + }).x + ).toBe(expectedX); + }); + + it('rejects wrong metadata dimensions for dimension-preserving filters', () => { + expect(() => + resolveFilterOutputRect({ + filterType: 'canny_edge_detection', + output: { height: 999, width: 999 }, + source, + }) + ).toThrow(LayerFilterOutputDimensionError); + }); + + it('retains the source rect for exact dimension-preserving output', () => { + expect( + resolveFilterOutputRect({ filterType: 'canny_edge_detection', output: { height: 60, width: 80 }, source }) + ).toEqual(source); + }); +}); + +describe('runLayerFilter', () => { + it('returns an actionable typed error for wrong dimension-preserving output metadata', async () => { + const surface = createTestStubRasterBackend().createSurface(80, 60); + + await expect( + runLayerFilter({ + deps: { + encodeSurface: () => Promise.resolve(new Blob()), + runFilterGraph: () => Promise.resolve({ height: 60, imageName: 'wrong', width: 79 }), + uploadIntermediate: () => Promise.resolve({ imageName: 'input' }), + }, + filterType: 'canny_edge_detection', + input: { rect: { height: 60, width: 80, x: 12, y: 24 }, surface }, + }) + ).rejects.toMatchObject({ + code: 'output-dimension', + message: 'Canny Edge Detection output dimensions 79x60 do not match source dimensions 80x60.', + }); + }); + + it('encodes, uploads, builds, and runs a filter in order', async () => { + const surface = createTestStubRasterBackend().createSurface(80, 60); + const blob = new Blob(['pixels'], { type: 'image/png' }); + const calls: string[] = []; + const signal = new AbortController().signal; + + const result = await runLayerFilter({ + deps: { + encodeSurface: (receivedSurface) => { + expect(receivedSurface).toBe(surface); + calls.push('encode'); + return Promise.resolve(blob); + }, + runFilterGraph: ({ graph, outputNodeId, signal: receivedSignal }) => { + calls.push('run'); + expect(graph.nodes.control_filter).toMatchObject({ + high_threshold: 200, + image: { image_name: 'input' }, + low_threshold: 100, + type: 'canny_edge_detection', + }); + expect(outputNodeId).toBe('control_filter'); + expect(receivedSignal).toBe(signal); + return Promise.resolve({ height: 60, imageName: 'output', width: 80 }); + }, + uploadIntermediate: (receivedBlob, receivedSignal) => { + expect(receivedBlob).toBe(blob); + expect(receivedSignal).toBe(signal); + calls.push('upload'); + return Promise.resolve({ imageName: 'input' }); + }, + }, + filterType: 'canny_edge_detection', + input: { rect: { height: 60, width: 80, x: 12, y: 24 }, surface }, + settings: { high_threshold: 200, low_threshold: 100 }, + signal, + }); + + expect(calls).toEqual(['encode', 'upload', 'run']); + expect(result).toEqual({ height: 60, imageName: 'output', origin: { x: 12, y: 24 }, width: 80 }); + }); + + it('rejects a pre-aborted request without invoking dependencies', async () => { + const controller = new AbortController(); + controller.abort(); + const calls: string[] = []; + + await expect( + runLayerFilter({ + deps: { + encodeSurface: () => { + calls.push('encode'); + return Promise.resolve(new Blob()); + }, + runFilterGraph: () => { + calls.push('run'); + return Promise.resolve({ height: 1, imageName: 'output', width: 1 }); + }, + uploadIntermediate: () => { + calls.push('upload'); + return Promise.resolve({ imageName: 'input' }); + }, + }, + filterType: 'canny_edge_detection', + input: { + rect: { height: 1, width: 1, x: 0, y: 0 }, + surface: createTestStubRasterBackend().createSurface(1, 1), + }, + signal: controller.signal, + }) + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(calls).toEqual([]); + }); + + it('does not build or run the graph after upload aborts the request', async () => { + const controller = new AbortController(); + const calls: string[] = []; + + await expect( + runLayerFilter({ + deps: { + encodeSurface: () => { + calls.push('encode'); + return Promise.resolve(new Blob()); + }, + runFilterGraph: () => { + calls.push('run'); + return Promise.resolve({ height: 1, imageName: 'output', width: 1 }); + }, + uploadIntermediate: () => { + calls.push('upload'); + controller.abort(); + return Promise.resolve({ imageName: 'input' }); + }, + }, + filterType: 'canny_edge_detection', + input: { + rect: { height: 1, width: 1, x: 0, y: 0 }, + surface: createTestStubRasterBackend().createSurface(1, 1), + }, + signal: controller.signal, + }) + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(calls).toEqual(['encode', 'upload']); + }); + + it('forwards cancellation into an in-flight upload', async () => { + const controller = new AbortController(); + let uploadSignal: AbortSignal | undefined; + + const pending = runLayerFilter({ + deps: { + encodeSurface: () => Promise.resolve(new Blob()), + runFilterGraph: () => Promise.resolve({ height: 1, imageName: 'output', width: 1 }), + uploadIntermediate: (_blob, signal) => { + uploadSignal = signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true }); + }); + }, + }, + filterType: 'canny_edge_detection', + input: { + rect: { height: 1, width: 1, x: 0, y: 0 }, + surface: createTestStubRasterBackend().createSurface(1, 1), + }, + signal: controller.signal, + }); + await Promise.resolve(); + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + expect(uploadSignal).toBe(controller.signal); + }); + + it('propagates a filter graph failure', async () => { + const graphError = new Error('graph failed'); + + await expect( + runLayerFilter({ + deps: { + encodeSurface: () => Promise.resolve(new Blob()), + runFilterGraph: () => Promise.reject(graphError), + uploadIntermediate: () => Promise.resolve({ imageName: 'input' }), + }, + filterType: 'canny_edge_detection', + input: { + rect: { height: 1, width: 1, x: 0, y: 0 }, + surface: createTestStubRasterBackend().createSurface(1, 1), + }, + }) + ).rejects.toBe(graphError); + }); + + it('preserves a negative layer-local origin', async () => { + const result = await runLayerFilter({ + deps: { + encodeSurface: () => Promise.resolve(new Blob()), + runFilterGraph: () => Promise.resolve({ height: 20, imageName: 'output', width: 30 }), + uploadIntermediate: () => Promise.resolve({ imageName: 'input' }), + }, + filterType: 'canny_edge_detection', + input: { + rect: { height: 20, width: 30, x: -12, y: -24 }, + surface: createTestStubRasterBackend().createSurface(30, 20), + }, + }); + + expect(result.origin).toEqual({ x: -12, y: -24 }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/layerFilterRunner.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/layerFilterRunner.ts new file mode 100644 index 00000000000..ce3b0e72848 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/layerFilterRunner.ts @@ -0,0 +1,92 @@ +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { BackendGraphContract } from '@workbench/types'; + +import { LayerFilterOutputDimensionError } from '@workbench/canvas-engine/filterError'; +import { buildFilterGraph } from '@workbench/generation/canvas/filterGraphs'; +export { LayerFilterOutputDimensionError } from '@workbench/canvas-engine/filterError'; + +export interface LayerFilterResult { + imageName: string; + width: number; + height: number; + origin: { x: number; y: number }; +} + +export interface RunLayerFilterOptions { + input: { surface: RasterSurface; rect: Rect }; + filterType: string; + settings?: Record; + signal?: AbortSignal; + deps: { + encodeSurface(surface: RasterSurface): Promise; + uploadIntermediate(blob: Blob, signal?: AbortSignal): Promise<{ imageName: string }>; + runFilterGraph(options: { + graph: BackendGraphContract; + outputNodeId: string; + signal?: AbortSignal; + }): Promise<{ height: number; imageName: string; width: number }>; + }; +} + +export const resolveFilterOutputRect = (options: { + filterType: string; + output: { width: number; height: number }; + settings?: Record; + source: Rect; +}): Rect => { + if (options.filterType === 'img_blur') { + const configuredRadius = options.settings?.radius; + const radius = + typeof configuredRadius === 'number' && Number.isFinite(configuredRadius) + ? Math.min(4_096, Math.max(0, configuredRadius)) + : 8; + const padding = Math.max(0, Math.ceil(radius * (options.settings?.blur_type === 'box' ? 1 : 3))); + return { + height: options.output.height, + width: options.output.width, + x: options.source.x - padding, + y: options.source.y - padding, + }; + } + if (options.filterType === 'spandrel_filter') { + return { ...options.output, x: options.source.x, y: options.source.y }; + } + if (options.output.width !== options.source.width || options.output.height !== options.source.height) { + throw new LayerFilterOutputDimensionError(options.filterType, options.output, options.source); + } + return { ...options.source }; +}; + +const throwIfAborted = (signal: AbortSignal | undefined): void => { + if (signal?.aborted) { + throw new DOMException('The layer-filter request was aborted.', 'AbortError'); + } +}; + +export const runLayerFilter = async (options: RunLayerFilterOptions): Promise => { + throwIfAborted(options.signal); + const blob = await options.deps.encodeSurface(options.input.surface); + throwIfAborted(options.signal); + const uploaded = await options.deps.uploadIntermediate(blob, options.signal); + throwIfAborted(options.signal); + const built = buildFilterGraph(options.filterType, uploaded.imageName, options.settings); + const output = await options.deps.runFilterGraph({ + graph: built.graph, + outputNodeId: built.outputNodeId, + signal: options.signal, + }); + throwIfAborted(options.signal); + const rect = resolveFilterOutputRect({ + filterType: options.filterType, + output, + settings: options.settings, + source: options.input.rect, + }); + return { + height: rect.height, + imageName: output.imageName, + origin: { x: rect.x, y: rect.y }, + width: rect.width, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/layerImageResult.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/layerImageResult.test.ts new file mode 100644 index 00000000000..3ab9ceb8aa3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/layerImageResult.test.ts @@ -0,0 +1,106 @@ +import type { LayerExportGuard } from '@workbench/canvas-engine/engine'; +import type { CanvasRasterLayerContractV2 } from '@workbench/types'; + +import { describe, expect, it, vi } from 'vitest'; + +import { prepareSelectObjectSource, processSelectObjectSource } from './layerImageResult'; + +const rect = { height: 12, width: 16, x: -5, y: 7 }; +const layer: CanvasRasterLayerContractV2 = { + blendMode: 'normal', + id: 'source', + isEnabled: true, + isLocked: false, + name: 'Source', + opacity: 1, + source: { fill: '#fff', height: 12, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 16 }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: -5, y: 7 }, + type: 'raster', +}; +const guard = { + cacheVersion: 1, + documentGeneration: 1, + layer, + layerId: layer.id, + projectId: 'p1', +} satisfies LayerExportGuard; + +describe('Select Object image processing', () => { + it('carries exact upload dimensions into the prepared source', async () => { + await expect( + prepareSelectObjectSource({ + exportSource: () => Promise.resolve({ blob: new Blob(), guard, rect, status: 'ok' }), + uploadIntermediate: () => Promise.resolve({ height: 12, imageName: 'source.png', width: 16 }), + }) + ).resolves.toEqual({ + source: { guard, height: 12, imageName: 'source.png', rect, width: 16 }, + status: 'ready', + }); + }); + + it.each([ + { height: 12, width: 15 }, + { height: 12.5, width: 16 }, + { height: 0, width: 16 }, + { height: Number.NaN, width: 16 }, + ])('rejects upload dimensions that do not exactly match the bbox: $width x $height', async (dimensions) => { + await expect( + prepareSelectObjectSource({ + exportSource: () => Promise.resolve({ blob: new Blob(), guard, rect, status: 'ok' }), + uploadIntermediate: () => Promise.resolve({ ...dimensions, imageName: 'source.png' }), + }) + ).resolves.toMatchObject({ status: 'dimension-mismatch' }); + }); + + it('rejects a visual input made empty by export-local conversion before enqueue', async () => { + const runGraph = vi.fn(); + await expect( + processSelectObjectSource({ + applyPolygonRefinement: false, + input: { + bbox: null, + excludePoints: [], + includePoints: [{ x: 100, y: 100 }], + type: 'visual', + }, + invert: false, + model: 'segment-anything-2-large', + runGraph, + source: { guard, height: 12, imageName: 'source.png', rect, width: 16 }, + }) + ).resolves.toEqual({ status: 'invalid-input' }); + expect(runGraph).not.toHaveBeenCalled(); + }); + + it('rejects a prepared source whose threaded upload dimensions no longer match its bbox', async () => { + const runGraph = vi.fn(); + await expect( + processSelectObjectSource({ + applyPolygonRefinement: false, + input: { prompt: 'cat', type: 'prompt' }, + invert: false, + model: 'segment-anything-2-large', + runGraph, + source: { guard, height: 12, imageName: 'source.png', rect, width: 15 }, + }) + ).resolves.toMatchObject({ status: 'dimension-mismatch' }); + expect(runGraph).not.toHaveBeenCalled(); + }); + + it.each([ + { height: 12, width: 15 }, + { height: 11.5, width: 16 }, + { height: Number.POSITIVE_INFINITY, width: 16 }, + ])('rejects mismatched SAM ImageOutput metadata without returning a stretchable preview', async (dimensions) => { + await expect( + processSelectObjectSource({ + applyPolygonRefinement: false, + input: { prompt: 'cat', type: 'prompt' }, + invert: false, + model: 'segment-anything-2-large', + runGraph: () => Promise.resolve({ ...dimensions, imageName: 'mask.png', origin: 'test' }), + source: { guard, height: 12, imageName: 'source.png', rect, width: 16 }, + }) + ).resolves.toMatchObject({ status: 'dimension-mismatch' }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/layerImageResult.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/layerImageResult.ts new file mode 100644 index 00000000000..9a10e27ac80 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/layerImageResult.ts @@ -0,0 +1,187 @@ +import type { ExportBakedLayerBlobResult, LayerExportGuard } from '@workbench/canvas-engine/engine'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { RunUtilityGraphOptions, UtilityGraphResult } from '@workbench/canvas-operations/backend/utilityQueue'; +import type { SamSessionErrorCode } from '@workbench/canvas-operations/operationTypes'; +import type { SamInput, SamModel } from '@workbench/generation/canvas/samGraph'; +import type { CanvasImageRef } from '@workbench/types'; + +import { UtilityQueueError } from '@workbench/canvas-operations/backend/utilityQueue'; +import { buildSamGraph, documentToExportLocalSamInput, isSamInputValid } from '@workbench/generation/canvas/samGraph'; + +export interface SelectObjectReadyResult { + status: 'ready'; + image: CanvasImageRef; + rect: Rect; + guard: LayerExportGuard; +} + +export type SelectObjectRunResult = + | SelectObjectReadyResult + | { status: 'aborted' | 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | { status: 'invalid-input' } + | { status: 'dimension-mismatch'; message: string } + | { status: 'failed'; message: string; code: SamSessionErrorCode }; + +export interface SelectObjectRunnerDeps { + exportSource(): Promise; + uploadIntermediate(blob: Blob, signal?: AbortSignal): Promise<{ height: number; imageName: string; width: number }>; + runGraph(options: Pick): Promise; +} + +const isAbortError = (error: unknown): boolean => error instanceof Error && error.name === 'AbortError'; +const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +export interface SelectObjectPreparedSource { + guard: LayerExportGuard; + imageName: string; + height: number; + rect: Rect; + width: number; +} + +export type PrepareSelectObjectSourceResult = + | { status: 'ready'; source: SelectObjectPreparedSource } + | Exclude; + +export const prepareSelectObjectSource = async ( + deps: Pick, + signal?: AbortSignal, + onPhase?: (phase: 'uploading') => void +): Promise => { + if (signal?.aborted) { + return { status: 'aborted' }; + } + let exported: ExportBakedLayerBlobResult; + try { + exported = await deps.exportSource(); + if (signal?.aborted) { + return { status: 'aborted' }; + } + if (exported.status !== 'ok') { + return exported; + } + } catch (error) { + if (signal?.aborted || isAbortError(error)) { + return { status: 'aborted' }; + } + return { code: 'unknown', message: errorMessage(error), status: 'failed' }; + } + + try { + onPhase?.('uploading'); + const uploaded = await deps.uploadIntermediate(exported.blob, signal); + if (signal?.aborted) { + return { status: 'aborted' }; + } + if (!hasExactDimensions(uploaded, exported.rect)) { + return { + message: `Uploaded Select Object source dimensions ${String(uploaded.width)}x${String(uploaded.height)} do not match ${exported.rect.width}x${exported.rect.height}.`, + status: 'dimension-mismatch', + }; + } + return { + source: { + guard: exported.guard, + height: uploaded.height, + imageName: uploaded.imageName, + rect: exported.rect, + width: uploaded.width, + }, + status: 'ready', + }; + } catch (error) { + if (signal?.aborted || isAbortError(error)) { + return { status: 'aborted' }; + } + return { code: 'upload', message: errorMessage(error), status: 'failed' }; + } +}; + +export interface ProcessSelectObjectSourceOptions { + source: SelectObjectPreparedSource; + input: SamInput; + model: SamModel; + invert: boolean; + applyPolygonRefinement: boolean; + signal?: AbortSignal; + runGraph: SelectObjectRunnerDeps['runGraph']; + onPhase?: (phase: 'processing-sam') => void; +} + +const hasExactDimensions = ( + value: { height: number; width: number }, + expected: { height: number; width: number } +): boolean => + Number.isFinite(value.width) && + Number.isInteger(value.width) && + value.width > 0 && + Number.isFinite(value.height) && + Number.isInteger(value.height) && + value.height > 0 && + value.width === expected.width && + value.height === expected.height; + +export const processSelectObjectSource = async ( + options: ProcessSelectObjectSourceOptions +): Promise => { + if (options.signal?.aborted) { + return { status: 'aborted' }; + } + try { + if (!hasExactDimensions(options.source, options.source.rect)) { + return { + message: `Uploaded Select Object source dimensions ${String(options.source.width)}x${String(options.source.height)} do not match ${options.source.rect.width}x${options.source.rect.height}.`, + status: 'dimension-mismatch', + }; + } + const input = documentToExportLocalSamInput(options.input, options.source.rect); + if (!isSamInputValid(input)) { + return { status: 'invalid-input' }; + } + const built = buildSamGraph({ + applyPolygonRefinement: options.applyPolygonRefinement, + imageName: options.source.imageName, + input, + invert: options.invert, + model: options.model, + }); + options.onPhase?.('processing-sam'); + const output = await options.runGraph({ + graph: built.graph, + outputNodeId: built.outputNodeId, + signal: options.signal, + }); + if (options.signal?.aborted) { + return { status: 'aborted' }; + } + if (!hasExactDimensions(output, options.source.rect)) { + return { + message: `SAM output dimensions ${String(output.width)}x${String(output.height)} do not match ${options.source.rect.width}x${options.source.rect.height}.`, + status: 'dimension-mismatch', + }; + } + return { + guard: options.source.guard, + image: { + height: output.height, + imageName: output.imageName, + width: output.width, + }, + rect: options.source.rect, + status: 'ready', + }; + } catch (error) { + if (options.signal?.aborted || isAbortError(error)) { + return { status: 'aborted' }; + } + const code: SamSessionErrorCode = + error instanceof UtilityQueueError + ? error.reason === 'no-output' + ? 'no-output' + : error.reason === 'reconcile' + ? 'reconcile' + : 'queue' + : 'queue'; + return { code, message: errorMessage(error), status: 'failed' }; + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/operationController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/operationController.test.ts new file mode 100644 index 00000000000..1327aac1f0f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/operationController.test.ts @@ -0,0 +1,513 @@ +import type { LayerExportGuard } from '@workbench/canvas-engine/api'; +import type { CanvasRasterLayerContractV2 } from '@workbench/types'; + +import { createCanvasEditGate } from '@workbench/canvas-engine/editGate'; +import { describe, expect, it, vi } from 'vitest'; + +import { createCanvasOperationController } from './operationController'; + +const layer: CanvasRasterLayerContractV2 = { + blendMode: 'normal', + id: 'layer-1', + isEnabled: true, + isLocked: false, + name: 'Layer', + opacity: 1, + source: { fill: '#fff', height: 10, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 10 }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}; + +const guard: LayerExportGuard = { + cacheVersion: 1, + documentGeneration: 1, + layer, + layerId: layer.id, + projectId: 'project-1', +}; + +const createDeferred = () => { + let resolve!: (value: T) => void; + let reject!: (cause: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +}; + +const filterIdentity = { kind: 'filter' as const, layerId: layer.id, projectId: 'project-1' }; +const selectObjectIdentity = { kind: 'select-object' as const, layerId: layer.id, projectId: 'project-1' }; + +describe('createCanvasOperationController', () => { + it('holds an exclusive edit lease and closes when the engine invalidates it', () => { + const edits = createCanvasEditGate(); + const controller = createCanvasOperationController({ edits, isGuardCurrent: () => true }); + const session = controller.start({ + cleanupPreview: vi.fn(), + guard, + identity: { kind: 'filter', layerId: guard.layerId, projectId: guard.projectId }, + }); + + expect(session).not.toBeNull(); + expect(edits.tryAcquire({ kind: 'other' })).toBeNull(); + edits.invalidateDocument(); + expect(controller.getSnapshot()).toEqual({ status: 'idle' }); + expect(edits.tryAcquire({ kind: 'other' })?.isCurrent()).toBe(true); + }); + it('requires the identity to name the guarded layer for both operation kinds', () => { + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + + const session = controller.start({ + cleanupPreview: vi.fn(), + guard, + identity: selectObjectIdentity, + }); + + expect(session).not.toBeNull(); + expect(controller.getSnapshot()).toMatchObject({ identity: selectObjectIdentity, status: 'active' }); + expect( + controller.start({ cleanupPreview: vi.fn(), guard, identity: { ...selectObjectIdentity, layerId: 'other' } }) + ).toBeNull(); + expect( + controller.start({ cleanupPreview: vi.fn(), guard, identity: { ...filterIdentity, layerId: 'other' } }) + ).toBeNull(); + expect(controller.getSnapshot()).toMatchObject({ identity: selectObjectIdentity, status: 'active' }); + }); + it('replaces the active operation and prevents its stale session from affecting the replacement', async () => { + const firstWork = createDeferred(); + const firstCleanup = vi.fn(); + const secondCleanup = vi.fn(); + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + const first = controller.start({ cleanupPreview: firstCleanup, guard, identity: filterIdentity })!; + const publishFirst = vi.fn((_result: string): undefined => undefined); + const pending = first.run(() => firstWork.promise, publishFirst); + firstCleanup.mockClear(); + + const second = controller.start({ + cleanupPreview: secondCleanup, + guard, + identity: selectObjectIdentity, + })!; + + expect(firstCleanup).toHaveBeenCalledOnce(); + expect(controller.getSnapshot()).toMatchObject({ identity: selectObjectIdentity, status: 'active' }); + first.cancel(); + firstWork.resolve('old'); + await expect(pending).resolves.toBe('stale'); + expect(publishFirst).not.toHaveBeenCalled(); + expect(secondCleanup).not.toHaveBeenCalled(); + expect(controller.getSnapshot()).toMatchObject({ identity: selectObjectIdentity, status: 'active' }); + second.cancel(); + }); + + it('reset aborts work and clears the preview while keeping the operation active', async () => { + const work = createDeferred(); + const cleanupPreview = vi.fn(); + let signal: AbortSignal | undefined; + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + const session = controller.start({ cleanupPreview, guard, identity: filterIdentity })!; + const pending = session.run( + (requestSignal) => { + signal = requestSignal; + return work.promise; + }, + vi.fn((): undefined => undefined) + ); + cleanupPreview.mockClear(); + + session.reset(); + + expect(signal?.aborted).toBe(true); + expect(cleanupPreview).toHaveBeenCalledOnce(); + expect(controller.getSnapshot()).toEqual({ + error: null, + identity: filterIdentity, + phase: 'ready', + status: 'active', + }); + work.resolve('late'); + await expect(pending).resolves.toBe('stale'); + }); + + it('cancel aborts work, clears the preview, and closes the operation', async () => { + const work = createDeferred(); + const cleanupPreview = vi.fn(); + let signal: AbortSignal | undefined; + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + const session = controller.start({ cleanupPreview, guard, identity: filterIdentity })!; + const pending = session.run( + (requestSignal) => { + signal = requestSignal; + return work.promise; + }, + vi.fn((): undefined => undefined) + ); + cleanupPreview.mockClear(); + + session.cancel(); + + expect(signal?.aborted).toBe(true); + expect(cleanupPreview).toHaveBeenCalledOnce(); + expect(controller.getSnapshot()).toEqual({ status: 'idle' }); + work.resolve('late'); + await expect(pending).resolves.toBe('stale'); + }); + + it('dispose aborts work, clears the preview, and stops notifications', async () => { + const work = createDeferred(); + const cleanupPreview = vi.fn(); + let signal: AbortSignal | undefined; + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + const session = controller.start({ cleanupPreview, guard, identity: filterIdentity })!; + const pending = session.run( + (requestSignal) => { + signal = requestSignal; + return work.promise; + }, + vi.fn((): undefined => undefined) + ); + const listener = vi.fn(); + controller.subscribe(listener); + cleanupPreview.mockClear(); + listener.mockClear(); + + controller.dispose(); + + expect(signal?.aborted).toBe(true); + expect(cleanupPreview).toHaveBeenCalledOnce(); + expect(controller.getSnapshot()).toEqual({ status: 'idle' }); + expect(listener).not.toHaveBeenCalled(); + expect(controller.start({ cleanupPreview, guard, identity: filterIdentity })).toBeNull(); + work.resolve('late'); + await expect(pending).resolves.toBe('stale'); + }); + + it('publishes only the latest request when completions arrive out of order', async () => { + const older = createDeferred(); + const newer = createDeferred(); + const publishOlder = vi.fn((_result: string): undefined => undefined); + const publishNewer = vi.fn((_result: string): undefined => undefined); + const signals: AbortSignal[] = []; + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + const session = controller.start({ cleanupPreview: vi.fn(), guard, identity: filterIdentity })!; + + const olderPending = session.run((signal) => { + signals.push(signal); + return older.promise; + }, publishOlder); + const newerPending = session.run((signal) => { + signals.push(signal); + return newer.promise; + }, publishNewer); + newer.resolve('newer'); + await expect(newerPending).resolves.toBe('published'); + older.resolve('older'); + await expect(olderPending).resolves.toBe('stale'); + + expect(signals[0]?.aborted).toBe(true); + expect(publishOlder).not.toHaveBeenCalled(); + expect(publishNewer).toHaveBeenCalledWith('newer'); + }); + + it('rechecks freshness after synchronous preview commit', async () => { + const replacementCleanup = vi.fn(); + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + const session = controller.start({ cleanupPreview: vi.fn(), guard, identity: filterIdentity })!; + + const result = await session.run( + () => Promise.resolve('prepared'), + () => { + controller.start({ + cleanupPreview: replacementCleanup, + guard, + identity: selectObjectIdentity, + }); + return undefined; + } + ); + + expect(result).toBe('stale'); + expect(controller.getSnapshot()).toMatchObject({ identity: selectObjectIdentity, status: 'active' }); + expect(replacementCleanup).not.toHaveBeenCalled(); + }); + + it('requires preview commit callbacks to be synchronous', () => { + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + const session = controller.start({ cleanupPreview: vi.fn(), guard, identity: filterIdentity })!; + + if (Date.now() < 0) { + const asyncCommit = (): Promise => Promise.resolve(); + const voidCommit = (): void => undefined; + const maybeAsyncCommit = (): void | Promise => Promise.resolve(); + // @ts-expect-error Preview commit must not yield after the final freshness check. + void session.run(() => Promise.resolve('prepared'), asyncCommit); + // @ts-expect-error Preview commit must explicitly return undefined. + void session.run(() => Promise.resolve('prepared'), voidCommit); + // @ts-expect-error Preview commit cannot have an async branch. + void session.run(() => Promise.resolve('prepared'), maybeAsyncCommit); + } + }); + + it('treats AbortError from a superseded request as stale without publishing an error', async () => { + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + const session = controller.start({ cleanupPreview: vi.fn(), guard, identity: filterIdentity })!; + const commitOlder = vi.fn((_result: string): undefined => undefined); + const older = session.run( + (signal) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new DOMException('superseded', 'AbortError')), { once: true }); + }), + commitOlder + ); + + const commitNewer = vi.fn((_result: string): undefined => undefined); + const newer = session.run(() => Promise.resolve('newer'), commitNewer); + + await expect(older).resolves.toBe('stale'); + await expect(newer).resolves.toBe('published'); + expect(commitOlder).not.toHaveBeenCalled(); + expect(commitNewer).toHaveBeenCalledWith('newer'); + expect(controller.getSnapshot()).toMatchObject({ error: null, phase: 'ready', status: 'active' }); + }); + + it.each(['replacement', 'reset', 'cancel'] as const)( + 'preserves an operation started reentrantly during %s cleanup', + (lifecycle) => { + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + const replacementCleanup = vi.fn(); + const cleanupPreview = vi.fn(() => { + controller.start({ + cleanupPreview: replacementCleanup, + guard, + identity: selectObjectIdentity, + }); + }); + const session = controller.start({ cleanupPreview, guard, identity: filterIdentity })!; + + if (lifecycle === 'replacement') { + controller.start({ cleanupPreview: vi.fn(), guard, identity: filterIdentity }); + } else if (lifecycle === 'reset') { + session.reset(); + } else { + session.cancel(); + } + + expect(cleanupPreview).toHaveBeenCalledOnce(); + expect(replacementCleanup).not.toHaveBeenCalled(); + expect(controller.getSnapshot()).toMatchObject({ identity: selectObjectIdentity, status: 'active' }); + } + ); + + it('preserves an operation started reentrantly during request cleanup', async () => { + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + const replacementCleanup = vi.fn(); + const cleanupPreview = vi.fn(() => { + controller.start({ + cleanupPreview: replacementCleanup, + guard, + identity: selectObjectIdentity, + }); + }); + const session = controller.start({ cleanupPreview, guard, identity: filterIdentity })!; + const work = vi.fn(() => Promise.resolve('prepared')); + + await expect( + session.run( + work, + vi.fn((): undefined => undefined) + ) + ).resolves.toBe('stale'); + + expect(cleanupPreview).toHaveBeenCalledOnce(); + expect(work).not.toHaveBeenCalled(); + expect(replacementCleanup).not.toHaveBeenCalled(); + expect(controller.getSnapshot()).toMatchObject({ identity: selectObjectIdentity, status: 'active' }); + }); + + it('contains subscriber exceptions during start and request state transitions', async () => { + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + const healthyListener = vi.fn(); + controller.subscribe(() => { + throw new Error('listener failed'); + }); + controller.subscribe(healthyListener); + + const session = controller.start({ cleanupPreview: vi.fn(), guard, identity: filterIdentity }); + + expect(session).not.toBeNull(); + await expect( + session!.run( + () => Promise.resolve('ready'), + vi.fn((): undefined => undefined) + ) + ).resolves.toBe('published'); + expect(healthyListener).toHaveBeenCalledTimes(3); + expect(controller.getSnapshot()).toMatchObject({ phase: 'ready', status: 'active' }); + }); + + it('rejects an invalid or mismatched export guard without replacing the active operation', () => { + const isGuardCurrent = vi.fn((candidate: LayerExportGuard) => candidate === guard); + const controller = createCanvasOperationController({ isGuardCurrent }); + const session = controller.start({ cleanupPreview: vi.fn(), guard, identity: filterIdentity }); + const staleGuard = { ...guard, cacheVersion: 2 }; + + expect(session).not.toBeNull(); + expect(controller.start({ cleanupPreview: vi.fn(), guard: staleGuard, identity: filterIdentity })).toBeNull(); + expect( + controller.start({ cleanupPreview: vi.fn(), guard, identity: { ...filterIdentity, layerId: 'other' } }) + ).toBeNull(); + expect( + controller.start({ cleanupPreview: vi.fn(), guard, identity: { ...filterIdentity, projectId: 'other' } }) + ).toBeNull(); + expect(controller.getSnapshot()).toMatchObject({ identity: filterIdentity, status: 'active' }); + }); + + it('suppresses a result when its guard becomes invalid during work', async () => { + let current = true; + const work = createDeferred(); + const publish = vi.fn((_result: string): undefined => undefined); + const cleanupPreview = vi.fn(); + const controller = createCanvasOperationController({ isGuardCurrent: () => current }); + const session = controller.start({ cleanupPreview, guard, identity: filterIdentity })!; + const pending = session.run(() => work.promise, publish); + cleanupPreview.mockClear(); + + current = false; + work.resolve('stale'); + + await expect(pending).resolves.toBe('stale'); + expect(publish).not.toHaveBeenCalled(); + expect(cleanupPreview).toHaveBeenCalledOnce(); + expect(controller.getSnapshot()).toEqual({ status: 'idle' }); + }); + + it.each([ + [ + 'source', + (controller: ReturnType) => + controller.invalidateSource('project-1', layer.id), + ], + [ + 'layer', + (controller: ReturnType) => + controller.invalidateLayer('project-1', layer.id), + ], + [ + 'project', + (controller: ReturnType) => controller.invalidateProject('project-1'), + ], + [ + 'document', + (controller: ReturnType) => controller.invalidateDocument('project-1'), + ], + ])('%s invalidation aborts and cleans the matching operation', async (_reason, invalidate) => { + const work = createDeferred(); + const cleanupPreview = vi.fn(); + let signal: AbortSignal | undefined; + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + const session = controller.start({ cleanupPreview, guard, identity: filterIdentity })!; + const pending = session.run( + (requestSignal) => { + signal = requestSignal; + return work.promise; + }, + vi.fn((): undefined => undefined) + ); + cleanupPreview.mockClear(); + + invalidate(controller); + + expect(signal?.aborted).toBe(true); + expect(cleanupPreview).toHaveBeenCalledOnce(); + expect(controller.getSnapshot()).toEqual({ status: 'idle' }); + work.resolve('late'); + await expect(pending).resolves.toBe('stale'); + }); + + it('ignores invalidation for a different target', () => { + const cleanupPreview = vi.fn(); + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + controller.start({ cleanupPreview, guard, identity: filterIdentity }); + + controller.invalidateSource('project-1', 'other-layer'); + controller.invalidateLayer('other-project', layer.id); + controller.invalidateProject('other-project'); + controller.invalidateDocument('other-project'); + + expect(cleanupPreview).not.toHaveBeenCalled(); + expect(controller.getSnapshot()).toMatchObject({ identity: filterIdentity, status: 'active' }); + }); + + it('keeps Select Object open across other-layer invalidation but closes it for its own source layer', () => { + const cleanupPreview = vi.fn(); + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + controller.start({ + cleanupPreview, + guard, + identity: selectObjectIdentity, + }); + + controller.invalidateSource('project-1', 'other-layer'); + controller.invalidateLayer('project-1', 'other-layer'); + + expect(cleanupPreview).not.toHaveBeenCalled(); + expect(controller.getSnapshot()).toMatchObject({ identity: selectObjectIdentity, status: 'active' }); + + controller.invalidateSource('project-1', layer.id); + expect(cleanupPreview).toHaveBeenCalledOnce(); + expect(controller.getSnapshot()).toEqual({ status: 'idle' }); + }); + + it('suppresses a late result after the operation closes', async () => { + const work = createDeferred(); + const publish = vi.fn((_result: string): undefined => undefined); + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + const session = controller.start({ + cleanupPreview: vi.fn(), + guard, + identity: selectObjectIdentity, + })!; + const pending = session.run(() => work.promise, publish); + + controller.cancel(); + work.resolve('late'); + + await expect(pending).resolves.toBe('stale'); + expect(publish).not.toHaveBeenCalled(); + expect(controller.getSnapshot()).toEqual({ status: 'idle' }); + }); + + it('publishes an error and allows the same operation to retry', async () => { + const cleanupPreview = vi.fn(); + const listener = vi.fn(); + const controller = createCanvasOperationController({ isGuardCurrent: () => true }); + const session = controller.start({ cleanupPreview, guard, identity: filterIdentity })!; + controller.subscribe(listener); + + await expect( + session.run( + () => Promise.reject(new Error('graph failed')), + vi.fn((): undefined => undefined) + ) + ).resolves.toBe('error'); + + expect(controller.getSnapshot()).toEqual({ + error: 'graph failed', + identity: filterIdentity, + phase: 'error', + status: 'active', + }); + + const publish = vi.fn((_result: string): undefined => undefined); + await expect(session.run(() => Promise.resolve('recovered'), publish)).resolves.toBe('published'); + expect(publish).toHaveBeenCalledWith('recovered'); + expect(controller.getSnapshot()).toEqual({ + error: null, + identity: filterIdentity, + phase: 'ready', + status: 'active', + }); + expect(listener).toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/operationController.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/operationController.ts new file mode 100644 index 00000000000..75a4fa3d60e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/operationController.ts @@ -0,0 +1,330 @@ +import type { LayerExportGuard } from '@workbench/canvas-engine/api'; +import type { CanvasEditGate, CanvasEditLease } from '@workbench/canvas-engine/editGate'; + +import { createCanvasEditGate } from '@workbench/canvas-engine/editGate'; + +export type CanvasOperationIdentity = + | { kind: 'select-object'; projectId: string; layerId: string } + | { kind: 'filter'; projectId: string; layerId: string }; + +export type CanvasOperationGuard = LayerExportGuard; + +export type CanvasOperationState = + | { status: 'idle' } + | { + status: 'active'; + identity: CanvasOperationIdentity; + phase: 'ready' | 'running' | 'error'; + error: string | null; + }; + +export type CanvasOperationRunResult = 'published' | 'stale' | 'error'; + +export interface CanvasOperationSession { + /** + * Prepares a preview asynchronously, then commits it synchronously only while + * this request and its export guard are still current. `commitPreview` must + * explicitly return `undefined`; do not cast or erase an async callback to + * `() => void`, which TypeScript cannot distinguish from a synchronous one. + */ + run( + work: (signal: AbortSignal) => Promise, + commitPreview: (result: T) => undefined + ): Promise; + reset(): void; + /** Aborts only the active request and keeps this operation ready for retry or Cancel. */ + interruptProcessing(): void; + cancel(): void; +} + +export interface StartCanvasOperationOptions { + identity: CanvasOperationIdentity; + guard: LayerExportGuard; + cleanupPreview(): void; +} + +export interface CanvasOperationController { + getSnapshot(): CanvasOperationState; + subscribe(listener: () => void): () => void; + start(options: StartCanvasOperationOptions): CanvasOperationSession | null; + reset(): void; + cancel(): void; + invalidateSource(projectId: string, layerId: string): void; + invalidateLayer(projectId: string, layerId: string): void; + invalidateProject(projectId: string): void; + invalidateDocument(projectId: string): void; + dispose(): void; +} + +export interface CanvasOperationControllerDeps { + isGuardCurrent(guard: CanvasOperationGuard): boolean; + edits?: CanvasEditGate; +} + +type ActiveOperation = StartCanvasOperationOptions & { + lease: CanvasEditLease; + onLeaseAbort: () => void; + requestController: AbortController | null; + requestToken: number; +}; + +const errorMessage = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause)); + +export const createCanvasOperationController = (deps: CanvasOperationControllerDeps): CanvasOperationController => { + const ownedEditGate = deps.edits ? null : createCanvasEditGate(); + const edits: CanvasEditGate = deps.edits ?? ownedEditGate!; + let state: CanvasOperationState = { status: 'idle' }; + let active: ActiveOperation | null = null; + let disposed = false; + const listeners = new Set<() => void>(); + + const publishState = (next: CanvasOperationState): void => { + state = next; + if (disposed) { + return; + } + for (const listener of listeners) { + try { + listener(); + } catch { + // One faulty subscriber must not strand the controller mid-transition. + } + } + }; + + const cleanupPreview = (operation: ActiveOperation): void => { + try { + operation.cleanupPreview(); + } catch { + // Cleanup is best-effort and must not prevent cancellation or invalidation. + } + }; + + const close = (operation: ActiveOperation): void => { + if (active !== operation) { + return; + } + operation.requestToken += 1; + const requestController = operation.requestController; + operation.requestController = null; + active = null; + const idleState: CanvasOperationState = { status: 'idle' }; + state = idleState; + requestController?.abort(); + operation.lease.signal.removeEventListener('abort', operation.onLeaseAbort); + operation.lease.release(); + cleanupPreview(operation); + if (active === null && state === idleState) { + publishState(idleState); + } + }; + + const interruptProcessing = (operation: ActiveOperation): void => { + if (active !== operation || disposed) { + return; + } + operation.requestToken += 1; + const requestController = operation.requestController; + operation.requestController = null; + active = null; + const idleState: CanvasOperationState = { status: 'idle' }; + state = idleState; + requestController?.abort(); + operation.lease.signal.removeEventListener('abort', operation.onLeaseAbort); + operation.lease.release(); + cleanupPreview(operation); + if (!disposed && active === null && state === idleState) { + const lease = edits.tryAcquire({ kind: operation.identity.kind, layerId: operation.identity.layerId }); + if (lease) { + operation.lease = lease; + lease.signal.addEventListener('abort', operation.onLeaseAbort, { once: true }); + active = operation; + publishState({ error: null, identity: operation.identity, phase: 'ready', status: 'active' }); + } else { + publishState(idleState); + } + } + }; + + const isGuardCurrent = (operation: ActiveOperation): boolean => { + try { + return deps.isGuardCurrent(operation.guard); + } catch { + return false; + } + }; + + const isCurrentRequest = (operation: ActiveOperation, token: number, controller: AbortController): boolean => + !disposed && + active === operation && + operation.requestToken === token && + operation.requestController === controller && + !controller.signal.aborted; + + const run = async ( + operation: ActiveOperation, + work: (signal: AbortSignal) => Promise, + commitPreview: (result: T) => undefined + ): Promise => { + if (disposed || active !== operation) { + return 'stale'; + } + if (!isGuardCurrent(operation)) { + close(operation); + return 'stale'; + } + + operation.requestToken += 1; + const token = operation.requestToken; + const previousController = operation.requestController; + operation.requestController = null; + active = null; + const idleState: CanvasOperationState = { status: 'idle' }; + state = idleState; + previousController?.abort(); + operation.lease.signal.removeEventListener('abort', operation.onLeaseAbort); + operation.lease.release(); + cleanupPreview(operation); + if (disposed || active !== null || state !== idleState) { + return 'stale'; + } + const lease = edits.tryAcquire({ kind: operation.identity.kind, layerId: operation.identity.layerId }); + if (!lease) { + publishState(idleState); + return 'stale'; + } + operation.lease = lease; + lease.signal.addEventListener('abort', operation.onLeaseAbort, { once: true }); + + const controller = new AbortController(); + operation.requestController = controller; + active = operation; + publishState({ error: null, identity: operation.identity, phase: 'running', status: 'active' }); + if (!isCurrentRequest(operation, token, controller)) { + return 'stale'; + } + + try { + const result = await work(controller.signal); + if (!isCurrentRequest(operation, token, controller)) { + return 'stale'; + } + if (!isGuardCurrent(operation)) { + close(operation); + return 'stale'; + } + + commitPreview(result); + if (!isCurrentRequest(operation, token, controller)) { + return 'stale'; + } + if (!isGuardCurrent(operation)) { + close(operation); + return 'stale'; + } + + operation.requestController = null; + publishState({ error: null, identity: operation.identity, phase: 'ready', status: 'active' }); + return 'published'; + } catch (cause) { + if (!isCurrentRequest(operation, token, controller)) { + return 'stale'; + } + operation.requestController = null; + publishState({ error: errorMessage(cause), identity: operation.identity, phase: 'error', status: 'active' }); + return 'error'; + } + }; + + const start = (options: StartCanvasOperationOptions): CanvasOperationSession | null => { + if ( + disposed || + options.identity.projectId !== options.guard.projectId || + options.identity.layerId !== options.guard.layerId + ) { + return null; + } + try { + if (!deps.isGuardCurrent(options.guard)) { + return null; + } + } catch { + return null; + } + + if (active) { + close(active); + if (active) { + return null; + } + } + const lease = edits.tryAcquire({ kind: options.identity.kind, layerId: options.identity.layerId }); + if (!lease) { + return null; + } + let operation!: ActiveOperation; + const onLeaseAbort = (): void => close(operation); + operation = { + ...options, + lease, + onLeaseAbort, + requestController: null, + requestToken: 0, + }; + lease.signal.addEventListener('abort', onLeaseAbort, { once: true }); + active = operation; + publishState({ error: null, identity: operation.identity, phase: 'ready', status: 'active' }); + + return { + cancel: () => close(operation), + interruptProcessing: () => interruptProcessing(operation), + reset: () => interruptProcessing(operation), + run: (work: (signal: AbortSignal) => Promise, commitPreview: (result: T) => undefined) => + run(operation, work, commitPreview), + }; + }; + + const invalidateTarget = (projectId: string, layerId?: string): void => { + if (active?.identity.projectId === projectId && (layerId === undefined || active.identity.layerId === layerId)) { + close(active); + } + }; + + return { + cancel: () => { + if (active) { + close(active); + } + }, + dispose: () => { + if (disposed) { + return; + } + disposed = true; + if (active) { + close(active); + } else { + state = { status: 'idle' }; + } + listeners.clear(); + ownedEditGate?.dispose(); + }, + getSnapshot: () => state, + invalidateDocument: (projectId) => invalidateTarget(projectId), + invalidateLayer: (projectId, layerId) => invalidateTarget(projectId, layerId), + invalidateProject: (projectId) => invalidateTarget(projectId), + invalidateSource: (projectId, layerId) => invalidateTarget(projectId, layerId), + reset: () => { + if (active) { + interruptProcessing(active); + } + }, + start, + subscribe: (listener) => { + if (!disposed) { + listeners.add(listener); + } + return () => listeners.delete(listener); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/operationStores.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/operationStores.test.ts new file mode 100644 index 00000000000..97ab9c47be1 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/operationStores.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createCanvasOperationStores } from './operationStores'; + +describe('createCanvasOperationStores', () => { + it('publishes filter and Select Object session snapshots independently', () => { + const stores = createCanvasOperationStores(); + const onFilter = vi.fn(); + const onSam = vi.fn(); + stores.filterSession.subscribe(onFilter); + stores.samSession.subscribe(onSam); + + stores.filterSession.set({ status: 'ready' } as never); + expect(onFilter).toHaveBeenCalledOnce(); + expect(onSam).not.toHaveBeenCalled(); + + stores.samSession.set({ status: 'ready' } as never); + expect(onSam).toHaveBeenCalledOnce(); + }); + + it('does not notify for an identical snapshot reference', () => { + const stores = createCanvasOperationStores(); + const listener = vi.fn(); + const snapshot = { status: 'ready' } as never; + stores.filterSession.subscribe(listener); + stores.filterSession.set(snapshot); + stores.filterSession.set(snapshot); + expect(listener).toHaveBeenCalledOnce(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/operationStores.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/operationStores.ts new file mode 100644 index 00000000000..9a5800d46c2 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/operationStores.ts @@ -0,0 +1,33 @@ +import type { + CanvasApplicationOperationStores, + CanvasApplicationScalarStore, +} from '@workbench/canvas-operations/contracts'; + +export type CanvasOperationScalarStore = CanvasApplicationScalarStore; +export type CanvasOperationStores = CanvasApplicationOperationStores; + +const createScalarStore = (initial: T): CanvasOperationScalarStore => { + let value = initial; + const listeners = new Set<() => void>(); + return { + get: () => value, + set: (next) => { + if (Object.is(value, next)) { + return; + } + value = next; + for (const listener of listeners) { + listener(); + } + }, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +}; + +export const createCanvasOperationStores = (): CanvasOperationStores => ({ + filterSession: createScalarStore(null), + samSession: createScalarStore(null), +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/operationTypes.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/operationTypes.ts new file mode 100644 index 00000000000..44867e71d3b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/operationTypes.ts @@ -0,0 +1,77 @@ +import type { LayerExportGuard } from '@workbench/canvas-engine/api'; +import type { + RasterFilterCommitTarget, + RasterFilterSettings, +} from '@workbench/canvas-engine/controllers/filterResultController'; +import type { SamVisualInput } from '@workbench/canvas-engine/samInteraction'; +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; +export type { SamVisualInput } from '@workbench/canvas-engine/samInteraction'; + +export type SamModel = 'segment-anything-2-large' | 'segment-anything-huge'; +export type SamPointLabel = 'include' | 'exclude'; +export type SamSessionErrorCode = + | 'invalid' + | 'not-ready' + | 'empty' + | 'upload' + | 'queue' + | 'no-output' + | 'reconcile' + | 'output-dimension' + | 'decode' + | 'locked' + | 'unknown'; +export interface SamSessionError { + code: SamSessionErrorCode; + detail?: string; +} +export type SamInput = + | { type: 'prompt'; prompt: string } + | { type: 'visual'; includePoints: readonly Vec2[]; excludePoints: readonly Vec2[]; bbox?: Rect | null }; +export type SamSessionInput = + | SamVisualInput + | { type: 'prompt'; prompt: string; includePoints?: never; excludePoints?: never; bbox?: never }; +export interface SamSessionSnapshot { + sourceRect: Rect; + layerName: string; + layerType: 'raster' | 'control'; + input: SamSessionInput; + pointLabel: SamPointLabel; + model: SamModel; + invert: boolean; + applyPolygonRefinement: boolean; + autoProcess: boolean; + isolatedPreview: boolean; + status: + | 'ready' + | 'scheduled' + | 'preparing-source' + | 'uploading' + | 'processing-sam' + | 'rendering-preview' + | 'committing' + | 'error'; + error: SamSessionError | null; + hasPreview: boolean; +} +export type LayerFilterSettings = RasterFilterSettings; +export interface FilterOperationPreview { + guard: LayerExportGuard; + imageName: string; + rect: Rect; + width: number; + height: number; + origin: { x: number; y: number }; +} +export interface FilterOperationSessionState { + autoProcess: boolean; + draft: LayerFilterSettings; + error: string | null; + initialFilter: LayerFilterSettings | null; + layerId: string; + layerName: string; + layerType: 'raster' | 'control'; + preview: FilterOperationPreview | null; + status: 'ready' | 'processing' | 'committing' | 'error'; +} +export type FilterCommitTarget = RasterFilterCommitTarget; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.test.ts new file mode 100644 index 00000000000..d7b33aa709f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.test.ts @@ -0,0 +1,253 @@ +import type { CanvasEngine } from '@workbench/canvas-engine/engine'; +import type { RasterCompositeExportResult } from '@workbench/canvas-engine/exportRasterComposite'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { GenerateModelConfig, GenerateWidgetValues, MainModelConfig } from '@workbench/generation/types'; +import type { CanvasDocumentContractV2, Project } from '@workbench/types'; + +import { getDefaultGenerateSettings } from '@workbench/generation/baseGenerationPolicies'; +import { getProjectWidgetInstance } from '@workbench/widgetState'; +import { createInitialWorkbenchState } from '@workbench/workbenchState'; +import { describe, expect, it, vi } from 'vitest'; + +import type { uploadCanvasImage } from './backend/canvasImages'; + +import { saveCanvasToGallery } from './saveCanvasToGallery'; + +const model: MainModelConfig = { + base: 'sd-1', + hash: 'model-hash', + key: 'model-key', + name: 'Test Model', + type: 'main', +}; + +const defaultRect: Rect = { height: 456, width: 123, x: -4, y: 8 }; +const defaultBlob = new Blob(['pixels'], { type: 'image/png' }); + +const createProject = (boardId = 'board-1', generateOverrides: Partial = {}): Project => { + const project = createInitialWorkbenchState().projects[0]!; + const generate = getProjectWidgetInstance(project, 'generate'); + const gallery = getProjectWidgetInstance(project, 'gallery'); + + if (!generate || !gallery) { + throw new Error('Expected initial project to contain generate and gallery widgets'); + } + + generate.state.values = { + ...getDefaultGenerateSettings(model), + model, + modelKey: model.key, + negativePromptEnabled: true, + negativePrompt: 'avoid blur', + positivePrompt: 'a canvas prompt', + seed: 123, + ...generateOverrides, + }; + gallery.state.values = { ...gallery.state.values, selectedBoardId: boardId }; + + return project; +}; + +const createDocument = (bbox: Rect): CanvasDocumentContractV2 => ({ bbox }) as unknown as CanvasDocumentContractV2; + +const createHarness = ( + options: { + document?: CanvasDocumentContractV2 | null; + exportResult?: RasterCompositeExportResult; + onFlush?: () => void; + } = {} +) => { + const order: string[] = []; + let document = options.document === undefined ? createDocument(defaultRect) : options.document; + const exportResult = options.exportResult ?? { blob: defaultBlob, rect: defaultRect, status: 'ok' }; + const flushPendingUploads = vi.fn(() => { + order.push('flush'); + options.onFlush?.(); + return Promise.resolve(); + }); + const getDocument = vi.fn(() => { + order.push('document'); + return document; + }); + const exportRasterComposite = vi.fn(() => { + order.push('export'); + return Promise.resolve(exportResult); + }); + const uploadImage = vi.fn(() => { + order.push('upload'); + return Promise.resolve({ height: defaultRect.height, imageName: 'saved.png', width: defaultRect.width }); + }); + const engine = { + document: { getDocument }, + exports: { exportRasterComposite }, + lifecycle: { flushPendingUploads }, + } as unknown as CanvasEngine; + + return { + engine, + exportRasterComposite, + flushPendingUploads, + getDocument, + order, + setDocument: (next: CanvasDocumentContractV2 | null) => { + document = next; + }, + uploadImage, + }; +}; + +describe('saveCanvasToGallery', () => { + it('flushes before exporting canvas content and uploads with gallery metadata', async () => { + const harness = createHarness(); + + await expect( + saveCanvasToGallery({ + engine: harness.engine, + project: createProject(), + region: 'canvas', + uploadImage: harness.uploadImage, + }) + ).resolves.toEqual({ imageName: 'saved.png', status: 'saved' }); + + expect(harness.order).toEqual(['flush', 'document', 'export', 'upload']); + expect(harness.exportRasterComposite).toHaveBeenCalledWith({ bounds: 'content' }); + expect(harness.uploadImage).toHaveBeenCalledWith(defaultBlob, { + boardId: 'board-1', + fileName: 'canvas.png', + imageCategory: 'general', + isIntermediate: false, + metadata: { + height: 456, + model: { base: 'sd-1', hash: 'model-hash', key: 'model-key', name: 'Test Model', type: 'main' }, + negative_prompt: 'avoid blur', + positive_prompt: 'a canvas prompt', + seed: 123, + width: 123, + }, + }); + }); + + it('exports the post-flush document bbox and uses the bbox filename', async () => { + const preFlushRect: Rect = { height: 20, width: 10, x: 0, y: 0 }; + const postFlushRect: Rect = { height: 40, width: 30, x: 5, y: 6 }; + let harness: ReturnType; + harness = createHarness({ + document: createDocument(preFlushRect), + onFlush: () => harness.setDocument(createDocument(postFlushRect)), + }); + + await saveCanvasToGallery({ + engine: harness.engine, + project: createProject(), + region: 'bbox', + uploadImage: harness.uploadImage, + }); + + expect(harness.exportRasterComposite).toHaveBeenCalledWith({ bounds: 'rect', rect: postFlushRect }); + expect(harness.uploadImage).toHaveBeenCalledWith(defaultBlob, expect.objectContaining({ fileName: 'bbox.png' })); + }); + + it.each([ + ['board-physical', 'board-physical'], + ['none', undefined], + ['by_date:2026-07-15', undefined], + ])('normalizes selected board %s to upload board %s', async (selectedBoardId, expectedBoardId) => { + const harness = createHarness(); + + await saveCanvasToGallery({ + engine: harness.engine, + project: createProject(selectedBoardId), + region: 'canvas', + uploadImage: harness.uploadImage, + }); + + expect(harness.uploadImage.mock.calls[0]?.[1]?.boardId).toBe(expectedBoardId); + }); + + it('omits disabled negative prompts and unsupported models from metadata', async () => { + const unsupportedModel: GenerateModelConfig = { + base: 'unsupported', + key: 'unsupported-model', + name: 'Unsupported Model', + type: 'main', + }; + const harness = createHarness(); + + await saveCanvasToGallery({ + engine: harness.engine, + project: createProject('none', { + model: unsupportedModel, + modelKey: unsupportedModel.key, + negativePromptEnabled: false, + }), + region: 'canvas', + uploadImage: harness.uploadImage, + }); + + const metadata = harness.uploadImage.mock.calls[0]?.[1]?.metadata; + expect(metadata).not.toHaveProperty('model'); + expect(metadata).not.toHaveProperty('negative_prompt'); + expect(metadata).toMatchObject({ height: 456, positive_prompt: 'a canvas prompt', seed: 123, width: 123 }); + }); + + it.each(['empty', 'stale', 'not-ready'] as const)('returns %s without uploading', async (status) => { + const harness = createHarness({ exportResult: { status } }); + + await expect( + saveCanvasToGallery({ + engine: harness.engine, + project: createProject(), + region: 'canvas', + uploadImage: harness.uploadImage, + }) + ).resolves.toEqual({ status }); + + expect(harness.uploadImage).not.toHaveBeenCalled(); + }); + + it('returns not-ready without exporting when there is no post-flush document', async () => { + const harness = createHarness({ document: null }); + + await expect( + saveCanvasToGallery({ + engine: harness.engine, + project: createProject(), + region: 'bbox', + uploadImage: harness.uploadImage, + }) + ).resolves.toEqual({ status: 'not-ready' }); + + expect(harness.exportRasterComposite).not.toHaveBeenCalled(); + expect(harness.uploadImage).not.toHaveBeenCalled(); + }); + + it('propagates export rejection without uploading', async () => { + const harness = createHarness(); + harness.exportRasterComposite.mockRejectedValueOnce(new Error('export failed')); + + await expect( + saveCanvasToGallery({ + engine: harness.engine, + project: createProject(), + region: 'canvas', + uploadImage: harness.uploadImage, + }) + ).rejects.toThrow('export failed'); + + expect(harness.uploadImage).not.toHaveBeenCalled(); + }); + + it('propagates upload rejection', async () => { + const harness = createHarness(); + harness.uploadImage.mockRejectedValueOnce(new Error('upload failed')); + + await expect( + saveCanvasToGallery({ + engine: harness.engine, + project: createProject(), + region: 'canvas', + uploadImage: harness.uploadImage, + }) + ).rejects.toThrow('upload failed'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.ts new file mode 100644 index 00000000000..3cc872c6bb9 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.ts @@ -0,0 +1,85 @@ +import type { + CanvasDocumentCapability, + CanvasExportCapability, + CanvasLifecycleCapability, +} from '@workbench/canvas-engine/api'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { Project } from '@workbench/types'; + +import { isDateBoardId } from '@workbench/gallery/api'; +import { isSupportedGenerateModel } from '@workbench/generation/baseGenerationPolicies'; +import { toModelIdentifier } from '@workbench/generation/graphBuilder'; +import { normalizeGenerateWidgetValues } from '@workbench/generation/settings'; +import { getProjectWidgetValues } from '@workbench/widgetState'; + +import { uploadCanvasImage } from './backend/canvasImages'; + +export type CanvasGallerySaveRegion = 'canvas' | 'bbox'; + +export type SaveCanvasToGalleryResult = + | { status: 'saved'; imageName: string } + | { status: 'empty' | 'stale' | 'not-ready' | 'over-budget' }; + +type CanvasGallerySaveEngine = { + readonly document: CanvasDocumentCapability; + readonly exports: Pick; + readonly lifecycle: Pick; +}; + +const buildCanvasSaveMetadata = (project: Project, rect: Rect): Record => { + const generateValues = normalizeGenerateWidgetValues(getProjectWidgetValues(project, 'generate')); + const dimensions = { height: rect.height, width: rect.width }; + + if (!generateValues) { + return dimensions; + } + + return { + ...dimensions, + ...(isSupportedGenerateModel(generateValues.model) ? { model: toModelIdentifier(generateValues.model) } : {}), + ...(generateValues.negativePromptEnabled ? { negative_prompt: generateValues.negativePrompt } : {}), + positive_prompt: generateValues.positivePrompt, + seed: generateValues.seed, + }; +}; + +const getCanvasSaveBoardId = (project: Project): string | undefined => { + const selectedBoardId = getProjectWidgetValues(project, 'gallery').selectedBoardId; + + return typeof selectedBoardId === 'string' && selectedBoardId !== 'none' && !isDateBoardId(selectedBoardId) + ? selectedBoardId + : undefined; +}; + +export const saveCanvasToGallery = async (options: { + engine: CanvasGallerySaveEngine; + region: CanvasGallerySaveRegion; + project: Project; + uploadImage?: typeof uploadCanvasImage; +}): Promise => { + const { engine, project, region, uploadImage = uploadCanvasImage } = options; + const boardId = getCanvasSaveBoardId(project); + + await engine.lifecycle.flushPendingUploads(); + const document = engine.document.getDocument(); + if (!document) { + return { status: 'not-ready' }; + } + + const exported = await engine.exports.exportRasterComposite( + region === 'canvas' ? { bounds: 'content' } : { bounds: 'rect', rect: document.bbox } + ); + if (exported.status !== 'ok') { + return exported; + } + + const uploaded = await uploadImage(exported.blob, { + boardId, + fileName: region === 'canvas' ? 'canvas.png' : 'bbox.png', + imageCategory: 'general', + isIntermediate: false, + metadata: buildCanvasSaveMetadata(project, exported.rect), + }); + + return { imageName: uploaded.imageName, status: 'saved' }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.test.ts new file mode 100644 index 00000000000..83cc88af1d1 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.test.ts @@ -0,0 +1,947 @@ +import type { LayerExportGuard } from '@workbench/canvas-engine/engine'; +import type { SamSessionError } from '@workbench/canvas-operations/operationTypes'; +import type { CanvasRasterLayerContractV2 } from '@workbench/types'; + +import { UtilityQueueError } from '@workbench/canvas-operations/backend/utilityQueue'; +import { createCanvasOperationController } from '@workbench/canvas-operations/operationController'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { SelectObjectSessionDeps } from './selectObjectSession'; + +import { createSelectObjectSession } from './selectObjectSession'; + +const layer: CanvasRasterLayerContractV2 = { + blendMode: 'normal', + id: 'source', + isEnabled: true, + isLocked: false, + name: 'Source', + opacity: 1, + source: { fill: '#fff', height: 12, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 16 }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: -5, y: 7 }, + type: 'raster', +}; +const guard: LayerExportGuard = { + cacheVersion: 3, + documentGeneration: 4, + layer, + layerId: layer.id, + projectId: 'p1', +}; +const replacementGuard: LayerExportGuard = { + ...guard, + cacheVersion: 4, +}; +const cloneGuard = (source: LayerExportGuard = guard): LayerExportGuard => ({ ...source }); +const rect = { height: 12, width: 16, x: -5, y: 7 }; +const blob = new Blob(['source'], { type: 'image/png' }); + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +const createHarness = (overrides: Partial> = {}) => { + let currentGuard: LayerExportGuard | null = guard; + let controllerSubscriber: (() => void) | null = null; + const unsubscribe = vi.fn(); + const baseController = createCanvasOperationController({ + isGuardCurrent: (candidate) => candidate === guard, + }); + const controller = { + ...baseController, + subscribe: vi.fn((listener: () => void) => { + controllerSubscriber = listener; + const detach = baseController.subscribe(listener); + return () => { + unsubscribe(); + detach(); + }; + }), + }; + const deps: SelectObjectSessionDeps = { + captureGuard: vi.fn(() => currentGuard), + controller, + decodePreview: vi.fn(({ image }) => Promise.resolve(`decoded:${image.imageName}`)), + exportSource: vi.fn(() => Promise.resolve({ blob, guard: currentGuard ?? guard, rect, status: 'ok' as const })), + isGuardCurrent: (candidate) => candidate === currentGuard, + publishPreview: vi.fn((): undefined => undefined), + cleanupPreview: vi.fn(), + runGraph: vi.fn(() => + Promise.resolve({ height: 12, imageName: 'result.png', origin: 'webv2:util:test', width: 16 }) + ), + uploadIntermediate: vi.fn(() => Promise.resolve({ height: 12, imageName: 'input.png', width: 16 })), + ...overrides, + }; + const operation = controller.start({ + cleanupPreview: deps.cleanupPreview, + guard, + identity: { kind: 'select-object', layerId: layer.id, projectId: 'p1' }, + }); + if (!operation) { + throw new Error('expected Select Object operation'); + } + const session = createSelectObjectSession({ deps, operation }); + session.update({ input: { prompt: 'cat', type: 'prompt' } }); + return { + controller, + deps, + fireControllerSubscriber() { + controllerSubscriber?.(); + }, + session, + setCurrentGuard(next: LayerExportGuard | null) { + currentGuard = next; + }, + unsubscribe, + }; +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('createSelectObjectSession', () => { + it('exposes the complete default processing state', () => { + const { session } = createHarness(); + + expect(session.getSnapshot()).toMatchObject({ + applyPolygonRefinement: false, + autoProcess: false, + error: null, + input: { prompt: 'cat', type: 'prompt' }, + invert: false, + isolatedPreview: true, + model: 'segment-anything-2-large', + preview: null, + sourceGuard: null, + status: 'ready', + }); + }); + + it('reports phases from the source export, upload, SAM, render, and ready boundaries', async () => { + const harness = createHarness(); + const phases: string[] = []; + harness.session.subscribe(() => phases.push(harness.session.getSnapshot().status)); + + await expect(harness.session.process()).resolves.toBe('published'); + + expect(phases).toEqual(['preparing-source', 'uploading', 'processing-sam', 'rendering-preview', 'ready']); + }); + + it('does not publish a stale request phase after a replacement retry starts', async () => { + const oldUpload = deferred<{ height: number; imageName: string; width: number }>(); + const harness = createHarness({ + uploadIntermediate: vi + .fn() + .mockImplementationOnce(() => oldUpload.promise) + .mockResolvedValueOnce({ height: 12, imageName: 'new-source.png', width: 16 }), + }); + const phases: string[] = []; + harness.session.subscribe(() => phases.push(harness.session.getSnapshot().status)); + const oldPending = harness.session.process(); + await vi.waitFor(() => expect(harness.session.getSnapshot().status).toBe('uploading')); + + harness.setCurrentGuard(replacementGuard); + vi.mocked(harness.deps.exportSource).mockResolvedValue({ blob, guard: replacementGuard, rect, status: 'ok' }); + const newPending = harness.session.process(); + await expect(newPending).resolves.toBe('published'); + const readyIndex = phases.lastIndexOf('ready'); + oldUpload.resolve({ height: 12, imageName: 'old-source.png', width: 16 }); + await expect(oldPending).resolves.toBe('stale'); + + expect(phases.slice(readyIndex + 1)).toEqual([]); + expect(harness.session.getSnapshot()).toMatchObject({ status: 'ready' }); + }); + + it('contains subscriber exceptions so healthy subscribers and processing continue', async () => { + const harness = createHarness(); + const throwing = vi.fn(() => { + throw new Error('subscriber failed'); + }); + const healthy = vi.fn(); + harness.session.subscribe(throwing); + harness.session.subscribe(healthy); + + expect(() => harness.session.update({ model: 'segment-anything-huge' })).not.toThrow(); + await expect(harness.session.process()).resolves.toBe('published'); + + expect(throwing).toHaveBeenCalled(); + expect(healthy).toHaveBeenCalled(); + expect(harness.session.getSnapshot()).toMatchObject({ error: null, status: 'ready' }); + }); + + it('never publishes error status without an error and returns to ready when update clears it', async () => { + const harness = createHarness(); + const snapshots: Array<{ error: SamSessionError | null; status: string }> = []; + harness.session.subscribe(() => { + const { error, status } = harness.session.getSnapshot(); + snapshots.push({ error, status }); + }); + harness.session.update({ input: { prompt: ' ', type: 'prompt' } }); + + await expect(harness.session.process()).resolves.toBe('invalid'); + expect(harness.session.getSnapshot()).toMatchObject({ + error: { code: 'invalid' }, + status: 'error', + }); + + harness.session.update({ autoProcess: false }); + + expect(harness.session.getSnapshot()).toMatchObject({ error: null, status: 'ready' }); + expect(snapshots.every(({ error, status }) => (status === 'error') === (error !== null))).toBe(true); + }); + + it('keeps a non-null error whenever processing fails', async () => { + const harness = createHarness({ runGraph: vi.fn(() => Promise.reject(new Error('graph failed'))) }); + + await expect(harness.session.process()).resolves.toBe('error'); + + expect(harness.session.getSnapshot()).toEqual( + expect.objectContaining({ error: { code: 'queue', detail: 'graph failed' }, status: 'error' }) + ); + }); + + it('retries the same input after a dimension mismatch and publishes the corrected output', async () => { + const runGraph = vi + .fn() + .mockResolvedValueOnce({ height: 12, imageName: 'bad.png', origin: 'test', width: 15 }) + .mockResolvedValueOnce({ height: 12, imageName: 'good.png', origin: 'test', width: 16 }); + const harness = createHarness({ runGraph }); + + await expect(harness.session.process()).resolves.toBe('error'); + expect(harness.session.getSnapshot()).toMatchObject({ preview: null, status: 'error' }); + await expect(harness.session.process()).resolves.toBe('published'); + + expect(runGraph).toHaveBeenCalledTimes(2); + expect(harness.session.getSnapshot()).toMatchObject({ error: null, status: 'ready' }); + expect(harness.session.getSnapshot().preview?.image.imageName).toBe('good.png'); + }); + + it('reports routing failures without discarding the preview and Reset clears the error', async () => { + const harness = createHarness(); + await harness.session.process(); + const preview = harness.session.getSnapshot().preview; + + harness.session.reportError('commit failed'); + + expect(harness.session.getSnapshot()).toMatchObject({ + error: { code: 'unknown', detail: 'commit failed' }, + preview, + status: 'error', + }); + harness.session.reset(); + expect(harness.session.getSnapshot()).toMatchObject({ error: null, preview: null, status: 'ready' }); + }); + + it.each([ + ['no-output', new UtilityQueueError('no-output', 'No output image.')], + ['reconcile', new UtilityQueueError('reconcile', 'Queue lookup failed.')], + ['queue', new UtilityQueueError('failed', 'SAM backend failed.')], + ] as const)('maps utility queue failures to %s session errors', async (code, cause) => { + const harness = createHarness({ runGraph: vi.fn(() => Promise.reject(cause)) }); + + await expect(harness.session.process()).resolves.toBe('error'); + + expect(harness.session.getSnapshot()).toMatchObject({ error: { code, detail: cause.message }, status: 'error' }); + }); + + it('preserves a backend exception type as queue diagnostics', async () => { + const harness = createHarness({ + runGraph: vi.fn(() => Promise.reject(new UtilityQueueError('failed', 'AttributeError'))), + }); + + await expect(harness.session.process()).resolves.toBe('error'); + + expect(harness.session.getSnapshot()).toMatchObject({ + error: { code: 'queue', detail: 'AttributeError' }, + status: 'error', + }); + }); + + it('maps upload failures to a typed upload error', async () => { + const harness = createHarness({ + uploadIntermediate: vi.fn(() => Promise.reject(new Error('upload service unavailable'))), + }); + + await expect(harness.session.process()).resolves.toBe('error'); + + expect(harness.session.getSnapshot()).toMatchObject({ + error: { code: 'upload', detail: 'upload service unavailable' }, + status: 'error', + }); + }); + + it('maps empty and not-ready sources to typed errors', async () => { + const empty = createHarness({ exportSource: vi.fn(() => Promise.resolve({ status: 'empty' as const })) }); + await expect(empty.session.process()).resolves.toBe('error'); + expect(empty.session.getSnapshot().error).toEqual({ code: 'empty' }); + + const notReady = createHarness(); + notReady.setCurrentGuard(null); + await expect(notReady.session.process()).resolves.toBe('error'); + expect(notReady.session.getSnapshot().error).toEqual({ code: 'not-ready' }); + }); + + it('maps output dimensions and decode failures to typed errors', async () => { + const uploadDimensions = createHarness({ + uploadIntermediate: vi.fn(() => Promise.resolve({ height: 12, imageName: 'bad-source.png', width: 15 })), + }); + await expect(uploadDimensions.session.process()).resolves.toBe('error'); + expect(uploadDimensions.session.getSnapshot().error).toMatchObject({ code: 'output-dimension' }); + + const dimensions = createHarness({ + runGraph: vi.fn(() => Promise.resolve({ height: 12, imageName: 'bad.png', origin: 'test', width: 15 })), + }); + await expect(dimensions.session.process()).resolves.toBe('error'); + expect(dimensions.session.getSnapshot().error).toMatchObject({ code: 'output-dimension' }); + + const decode = createHarness({ decodePreview: vi.fn(() => Promise.reject(new Error('corrupt PNG'))) }); + await expect(decode.session.process()).resolves.toBe('error'); + expect(decode.session.getSnapshot().error).toEqual({ code: 'decode', detail: 'corrupt PNG' }); + }); + + it('accepts typed locked errors while preserving string compatibility as unknown diagnostics', () => { + const harness = createHarness(); + + harness.session.reportError({ code: 'locked' }); + expect(harness.session.getSnapshot().error).toEqual({ code: 'locked' }); + + harness.session.reportError('legacy failure detail'); + expect(harness.session.getSnapshot().error).toEqual({ code: 'unknown', detail: 'legacy failure detail' }); + }); + + it('survives source invalidation of other layers but closes when its own layer is invalidated', async () => { + const harness = createHarness({ runGraph: vi.fn(() => Promise.reject(new Error('graph failed'))) }); + await expect(harness.session.process()).resolves.toBe('error'); + + harness.controller.invalidateSource('p1', 'other-layer'); + + expect(harness.session.getSnapshot()).toMatchObject({ error: { code: 'queue' }, status: 'error' }); + expect(harness.controller.getSnapshot()).toMatchObject({ status: 'active' }); + + harness.controller.invalidateSource('p1', layer.id); + + expect(harness.controller.getSnapshot()).toMatchObject({ status: 'idle' }); + expect(harness.session.getSnapshot()).toMatchObject({ preview: null, sourceGuard: null, status: 'ready' }); + }); + + it('reuses an uploaded export only while its exact guard remains current', async () => { + const harness = createHarness(); + + await expect(harness.session.process()).resolves.toBe('published'); + harness.session.update({ input: { prompt: 'dog', type: 'prompt' } }); + await expect(harness.session.process()).resolves.toBe('published'); + + expect(harness.deps.exportSource).toHaveBeenCalledTimes(1); + expect(harness.deps.uploadIntermediate).toHaveBeenCalledTimes(1); + expect(harness.deps.runGraph).toHaveBeenCalledTimes(2); + expect(harness.session.getSnapshot().sourceGuard).toBe(guard); + + harness.setCurrentGuard(replacementGuard); + vi.mocked(harness.deps.exportSource).mockResolvedValue({ + blob, + guard: replacementGuard, + rect, + status: 'ok', + }); + harness.session.update({ input: { prompt: 'bird', type: 'prompt' } }); + await expect(harness.session.process()).resolves.toBe('published'); + + expect(harness.deps.exportSource).toHaveBeenCalledTimes(2); + expect(harness.deps.uploadIntermediate).toHaveBeenCalledTimes(2); + expect(harness.session.getSnapshot().sourceGuard).toBe(replacementGuard); + }); + + it('rebinds a prepared source across fresh structurally equal guards without another export or upload', async () => { + let currentGuard = cloneGuard(); + const harness = createHarness({ + captureGuard: () => currentGuard, + exportSource: vi.fn(() => Promise.resolve({ blob, guard: currentGuard, rect, status: 'ok' as const })), + isGuardCurrent: (candidate) => + candidate.cacheVersion === currentGuard.cacheVersion && + candidate.documentGeneration === currentGuard.documentGeneration, + }); + harness.session.update({ + input: { bbox: null, excludePoints: [], includePoints: [{ x: -4, y: 8 }], type: 'visual' }, + }); + + await expect(harness.session.process()).resolves.toBe('published'); + for (const update of [ + { input: { bbox: null, excludePoints: [], includePoints: [{ x: -3, y: 9 }], type: 'visual' as const } }, + { invert: true }, + { applyPolygonRefinement: true }, + ]) { + currentGuard = cloneGuard(); + harness.session.update(update); + await expect(harness.session.process()).resolves.toBe('published'); + expect(harness.session.getSnapshot().sourceGuard).toBe(currentGuard); + } + + expect(harness.deps.exportSource).toHaveBeenCalledOnce(); + expect(harness.deps.uploadIntermediate).toHaveBeenCalledOnce(); + expect(harness.deps.runGraph).toHaveBeenCalledTimes(4); + }); + + it.each([ + ['layer reference', { ...cloneGuard(), layer: { ...layer } }], + ['cache version', cloneGuard(replacementGuard)], + [ + 'document generation', + { + ...cloneGuard(), + documentGeneration: guard.documentGeneration + 1, + }, + ], + ] as const)('does not reuse the prepared source after a changed %s guard', async (_label, nextGuard) => { + let currentGuard: LayerExportGuard = guard; + const harness = createHarness({ + captureGuard: () => currentGuard, + exportSource: vi.fn(() => Promise.resolve({ blob, guard: currentGuard, rect, status: 'ok' as const })), + isGuardCurrent: (candidate) => candidate === currentGuard, + }); + await expect(harness.session.process()).resolves.toBe('published'); + + currentGuard = nextGuard; + harness.session.update({ input: { prompt: 'dog', type: 'prompt' } }); + await expect(harness.session.process()).resolves.toBe('published'); + + expect(harness.deps.exportSource).toHaveBeenCalledTimes(2); + expect(harness.deps.uploadIntermediate).toHaveBeenCalledTimes(2); + }); + + it.each(['layer', 'project', 'document'] as const)( + 'invalidates the cached export and preview on %s invalidation', + async (kind) => { + const harness = createHarness(); + await harness.session.process(); + vi.mocked(harness.deps.cleanupPreview).mockClear(); + + if (kind === 'layer') { + harness.controller.invalidateLayer('p1', layer.id); + } else if (kind === 'project') { + harness.controller.invalidateProject('p1'); + } else { + harness.controller.invalidateDocument('p1'); + } + + expect(harness.session.getSnapshot()).toMatchObject({ preview: null, sourceGuard: null, status: 'ready' }); + expect(harness.deps.cleanupPreview).toHaveBeenCalledOnce(); + } + ); + + it('starts an immediate same-input retry after invalidation when old work ignores abort', async () => { + const older = deferred<{ height: number; imageName: string; origin: string; width: number }>(); + const newer = deferred<{ height: number; imageName: string; origin: string; width: number }>(); + const harness = createHarness({ + runGraph: vi + .fn() + .mockImplementationOnce(() => older.promise) + .mockImplementationOnce(() => newer.promise), + }); + const oldPending = harness.session.process(); + await vi.waitFor(() => expect(harness.deps.runGraph).toHaveBeenCalledOnce()); + + harness.setCurrentGuard(replacementGuard); + vi.mocked(harness.deps.exportSource).mockResolvedValue({ blob, guard: replacementGuard, rect, status: 'ok' }); + const newPending = harness.session.process(); + + expect(newPending).not.toBe(oldPending); + await vi.waitFor(() => expect(harness.deps.runGraph).toHaveBeenCalledTimes(2)); + expect(harness.deps.exportSource).toHaveBeenCalledTimes(2); + older.resolve({ height: 12, imageName: 'old.png', origin: 'old', width: 16 }); + await expect(oldPending).resolves.toBe('stale'); + expect(harness.session.process()).toBe(newPending); + + newer.resolve({ height: 12, imageName: 'new.png', origin: 'new', width: 16 }); + await expect(newPending).resolves.toBe('published'); + expect(harness.session.getSnapshot().preview?.image.imageName).toBe('new.png'); + await expect(harness.session.process()).resolves.toBe('deduped'); + expect(harness.deps.runGraph).toHaveBeenCalledTimes(2); + }); + + it('starts an immediate same-input retry when invalidation aborts an export that ignores its signal', async () => { + const oldExport = deferred['exportSource']>>>(); + const harness = createHarness({ + exportSource: vi + .fn() + .mockImplementationOnce(() => oldExport.promise) + .mockImplementationOnce(() => Promise.resolve({ blob, guard: replacementGuard, rect, status: 'ok' as const })), + }); + const oldPending = harness.session.process(); + await vi.waitFor(() => expect(harness.deps.exportSource).toHaveBeenCalledOnce()); + + harness.setCurrentGuard(replacementGuard); + vi.mocked(harness.deps.exportSource).mockResolvedValue({ blob, guard: replacementGuard, rect, status: 'ok' }); + const newPending = harness.session.process(); + + expect(newPending).not.toBe(oldPending); + await expect(newPending).resolves.toBe('published'); + expect(harness.deps.exportSource).toHaveBeenCalledTimes(2); + expect(harness.deps.uploadIntermediate).toHaveBeenCalledOnce(); + expect(harness.deps.runGraph).toHaveBeenCalledOnce(); + oldExport.resolve({ blob, guard, rect, status: 'ok' }); + await expect(oldPending).resolves.toBe('stale'); + expect(harness.session.getSnapshot().preview?.image.imageName).toBe('result.png'); + await expect(harness.session.process()).resolves.toBe('deduped'); + }); + + it('starts an immediate same-input retry when invalidation aborts an upload that ignores its signal', async () => { + const oldUpload = deferred<{ height: number; imageName: string; width: number }>(); + const harness = createHarness({ + uploadIntermediate: vi + .fn() + .mockImplementationOnce(() => oldUpload.promise) + .mockImplementationOnce(() => Promise.resolve({ height: 12, imageName: 'new-input.png', width: 16 })), + }); + const oldPending = harness.session.process(); + await vi.waitFor(() => expect(harness.deps.uploadIntermediate).toHaveBeenCalledOnce()); + + harness.setCurrentGuard(replacementGuard); + vi.mocked(harness.deps.exportSource).mockResolvedValue({ blob, guard: replacementGuard, rect, status: 'ok' }); + const newPending = harness.session.process(); + + expect(newPending).not.toBe(oldPending); + await expect(newPending).resolves.toBe('published'); + expect(harness.deps.exportSource).toHaveBeenCalledTimes(2); + expect(harness.deps.uploadIntermediate).toHaveBeenCalledTimes(2); + expect(harness.deps.runGraph).toHaveBeenCalledOnce(); + oldUpload.resolve({ height: 12, imageName: 'old-input.png', width: 16 }); + await expect(oldPending).resolves.toBe('stale'); + expect(harness.session.getSnapshot().preview?.sourceImageName).toBe('new-input.png'); + await expect(harness.session.process()).resolves.toBe('deduped'); + }); + + it('deduplicates an identical stable input hash after publication', async () => { + const harness = createHarness(); + + await expect(harness.session.process()).resolves.toBe('published'); + await expect(harness.session.process()).resolves.toBe('deduped'); + + expect(harness.deps.runGraph).toHaveBeenCalledOnce(); + }); + + it('transitions an identical scheduled auto-run back to ready and preserves its preview', async () => { + vi.useFakeTimers(); + const harness = createHarness(); + await harness.session.process(); + const preview = harness.session.getSnapshot().preview; + const listener = vi.fn(); + harness.session.subscribe(listener); + + harness.session.update({ autoProcess: true }); + expect(harness.session.getSnapshot().status).toBe('scheduled'); + listener.mockClear(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(harness.session.getSnapshot()).toMatchObject({ error: null, preview, status: 'ready' }); + expect(listener).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + expect(harness.deps.runGraph).toHaveBeenCalledOnce(); + }); + + it('transitions an identical scheduled auto-run back to processing when work is already in flight', async () => { + vi.useFakeTimers(); + const graph = deferred<{ height: number; imageName: string; origin: string; width: number }>(); + const harness = createHarness({ runGraph: vi.fn(() => graph.promise) }); + const pending = harness.session.process(); + await vi.waitFor(() => expect(harness.deps.runGraph).toHaveBeenCalledOnce()); + + harness.session.update({ autoProcess: true }); + expect(harness.session.getSnapshot().status).toBe('scheduled'); + await vi.advanceTimersByTimeAsync(1_000); + + expect(harness.session.getSnapshot()).toMatchObject({ error: null, status: 'processing-sam' }); + expect(vi.getTimerCount()).toBe(0); + expect(harness.deps.runGraph).toHaveBeenCalledOnce(); + graph.resolve({ height: 12, imageName: 'result.png', origin: 'test', width: 16 }); + await expect(pending).resolves.toBe('published'); + }); + + it('does not deduplicate the same hash after its exact source guard is replaced', async () => { + const harness = createHarness(); + await expect(harness.session.process()).resolves.toBe('published'); + + harness.setCurrentGuard(replacementGuard); + vi.mocked(harness.deps.exportSource).mockResolvedValue({ blob, guard: replacementGuard, rect, status: 'ok' }); + + await expect(harness.session.process()).resolves.toBe('published'); + expect(harness.deps.exportSource).toHaveBeenCalledTimes(2); + expect(harness.deps.uploadIntermediate).toHaveBeenCalledTimes(2); + expect(harness.deps.runGraph).toHaveBeenCalledTimes(2); + expect(harness.session.getSnapshot().sourceGuard).toBe(replacementGuard); + }); + + it('debounces auto-processing for one second and manual processing runs immediately', async () => { + vi.useFakeTimers(); + const harness = createHarness(); + harness.session.update({ autoProcess: true }); + harness.session.update({ input: { prompt: 'first', type: 'prompt' } }); + await vi.advanceTimersByTimeAsync(500); + harness.session.update({ input: { prompt: 'second', type: 'prompt' } }); + await vi.advanceTimersByTimeAsync(999); + expect(harness.deps.exportSource).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await vi.waitFor(() => expect(harness.deps.runGraph).toHaveBeenCalledOnce()); + + harness.session.update({ input: { prompt: 'manual', type: 'prompt' } }); + await expect(harness.session.process()).resolves.toBe('published'); + expect(harness.deps.runGraph).toHaveBeenCalledTimes(2); + }); + + it('immediately stales a delayed auto-run when input changes before the replacement debounce', async () => { + vi.useFakeTimers(); + const oldGraph = deferred<{ height: number; imageName: string; origin: string; width: number }>(); + const harness = createHarness({ runGraph: vi.fn(() => oldGraph.promise) }); + harness.session.update({ autoProcess: true }); + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(harness.deps.runGraph).toHaveBeenCalledOnce()); + + harness.session.update({ input: { prompt: 'dog', type: 'prompt' } }); + expect(harness.session.getSnapshot()).toMatchObject({ preview: null, status: 'scheduled' }); + oldGraph.resolve({ height: 12, imageName: 'old.png', origin: 'old', width: 16 }); + await vi.advanceTimersByTimeAsync(999); + + expect(harness.deps.publishPreview).not.toHaveBeenCalled(); + expect(harness.deps.runGraph).toHaveBeenCalledOnce(); + }); + + it.each([ + ['input', { input: { prompt: 'dog', type: 'prompt' as const } }], + ['model', { model: 'segment-anything-huge' as const }], + ['invert', { invert: true }], + ['refinement', { applyPolygonRefinement: true }], + ] as const)('immediately aborts in-flight work and clears preview for a %s update', async (_label, update) => { + const signals: AbortSignal[] = []; + const graph = deferred<{ height: number; imageName: string; origin: string; width: number }>(); + const harness = createHarness({ + runGraph: vi.fn((options) => { + if (options.signal) { + signals.push(options.signal); + } + return graph.promise; + }), + }); + const pending = harness.session.process(); + await vi.waitFor(() => expect(harness.deps.runGraph).toHaveBeenCalledOnce()); + + harness.session.update(update); + + expect(signals[0]?.aborted).toBe(true); + expect(harness.session.getSnapshot().preview).toBeNull(); + graph.resolve({ height: 12, imageName: 'stale.png', origin: 'stale', width: 16 }); + await expect(pending).resolves.toBe('stale'); + expect(harness.deps.publishPreview).not.toHaveBeenCalled(); + }); + + it('changes ready preview isolation without clearing or reprocessing it', async () => { + const harness = createHarness(); + await harness.session.process(); + const preview = harness.session.getSnapshot().preview; + vi.mocked(harness.deps.publishPreview).mockClear(); + vi.mocked(harness.deps.runGraph).mockClear(); + vi.mocked(harness.deps.cleanupPreview).mockClear(); + + harness.session.update({ isolatedPreview: false }); + + expect(harness.session.getSnapshot()).toMatchObject({ isolatedPreview: false }); + expect(harness.session.getSnapshot().preview).toMatchObject({ data: preview?.data, isolated: false }); + expect(harness.deps.publishPreview).toHaveBeenCalledOnce(); + expect(harness.deps.cleanupPreview).not.toHaveBeenCalled(); + expect(harness.deps.runGraph).not.toHaveBeenCalled(); + }); + + it('changes isolation during processing without aborting or rerunning the request', async () => { + const graph = deferred<{ height: number; imageName: string; origin: string; width: number }>(); + const signals: AbortSignal[] = []; + const harness = createHarness({ + runGraph: vi.fn((options) => { + if (options.signal) { + signals.push(options.signal); + } + return graph.promise; + }), + }); + const pending = harness.session.process(); + await vi.waitFor(() => expect(harness.deps.runGraph).toHaveBeenCalledOnce()); + + harness.session.update({ isolatedPreview: false }); + graph.resolve({ height: 12, imageName: 'result.png', origin: 'test', width: 16 }); + + await expect(pending).resolves.toBe('published'); + expect(signals[0]?.aborted).toBe(false); + expect(harness.deps.runGraph).toHaveBeenCalledOnce(); + expect(harness.session.getSnapshot().preview).toMatchObject({ isolated: false }); + }); + + it('clears an already-published preview as soon as processing input changes', async () => { + const harness = createHarness(); + await harness.session.process(); + vi.mocked(harness.deps.cleanupPreview).mockClear(); + + harness.session.update({ model: 'segment-anything-huge' }); + + expect(harness.session.getSnapshot()).toMatchObject({ preview: null, status: 'ready' }); + expect(harness.deps.cleanupPreview).toHaveBeenCalledOnce(); + }); + + it('rejects non-finite document input before hashing or scheduling work', async () => { + vi.useFakeTimers(); + const harness = createHarness(); + harness.session.update({ autoProcess: true }); + harness.session.update({ + input: { + bbox: null, + excludePoints: [], + includePoints: [{ x: Number.NaN, y: 1 }], + type: 'visual', + }, + }); + + await expect(harness.session.process()).resolves.toBe('invalid'); + await vi.runAllTimersAsync(); + expect(harness.deps.exportSource).not.toHaveBeenCalled(); + expect(harness.deps.runGraph).not.toHaveBeenCalled(); + }); + + it('publishes only the latest request when graph completions arrive out of order', async () => { + const older = deferred<{ height: number; imageName: string; origin: string; width: number }>(); + const newer = deferred<{ height: number; imageName: string; origin: string; width: number }>(); + const harness = createHarness({ + runGraph: vi + .fn() + .mockImplementationOnce(() => older.promise) + .mockImplementationOnce(() => newer.promise), + }); + const oldPending = harness.session.process(); + await vi.waitFor(() => expect(harness.deps.runGraph).toHaveBeenCalledOnce()); + harness.session.update({ input: { prompt: 'dog', type: 'prompt' } }); + const newPending = harness.session.process(); + await vi.waitFor(() => expect(harness.deps.runGraph).toHaveBeenCalledTimes(2)); + + newer.resolve({ height: 12, imageName: 'new.png', origin: 'new', width: 16 }); + await expect(newPending).resolves.toBe('published'); + older.resolve({ height: 12, imageName: 'old.png', origin: 'old', width: 16 }); + await expect(oldPending).resolves.toBe('stale'); + + expect(harness.deps.publishPreview).toHaveBeenCalledOnce(); + expect(harness.session.getSnapshot().preview?.image.imageName).toBe('new.png'); + }); + + it.each(['export', 'upload', 'queue', 'decode'] as const)( + 'cancels safely at the %s boundary without starting later work', + async (boundary) => { + const wait = deferred(); + const harness = createHarness({ + decodePreview: + boundary === 'decode' + ? vi.fn(() => wait.promise as Promise) + : vi.fn(({ image }) => Promise.resolve(`decoded:${image.imageName}`)), + exportSource: + boundary === 'export' + ? vi.fn(() => wait.promise as ReturnType['exportSource']>) + : vi.fn(() => Promise.resolve({ blob, guard, rect, status: 'ok' as const })), + runGraph: + boundary === 'queue' + ? vi.fn(() => wait.promise as ReturnType['runGraph']>) + : vi.fn(() => Promise.resolve({ height: 12, imageName: 'result.png', origin: 'test', width: 16 })), + uploadIntermediate: + boundary === 'upload' + ? vi.fn(() => wait.promise as ReturnType['uploadIntermediate']>) + : vi.fn(() => Promise.resolve({ height: 12, imageName: 'input.png', width: 16 })), + }); + const pending = harness.session.process(); + await vi.waitFor(() => { + const fn = + boundary === 'export' + ? harness.deps.exportSource + : boundary === 'upload' + ? harness.deps.uploadIntermediate + : boundary === 'queue' + ? harness.deps.runGraph + : harness.deps.decodePreview; + expect(fn).toHaveBeenCalledOnce(); + }); + + harness.session.cancel(); + if (boundary === 'export') { + wait.resolve({ blob, guard, rect, status: 'ok' }); + } else if (boundary === 'upload') { + wait.resolve({ height: 12, imageName: 'input.png', width: 16 }); + } else if (boundary === 'queue') { + wait.resolve({ height: 12, imageName: 'result.png', origin: 'test', width: 16 }); + } else { + wait.resolve('decoded'); + } + + await expect(pending).resolves.toBe('stale'); + expect(harness.deps.publishPreview).not.toHaveBeenCalled(); + expect(harness.deps.uploadIntermediate).toHaveBeenCalledTimes(boundary === 'export' ? 0 : 1); + expect(harness.deps.runGraph).toHaveBeenCalledTimes(boundary === 'export' || boundary === 'upload' ? 0 : 1); + expect(harness.deps.decodePreview).toHaveBeenCalledTimes( + boundary === 'export' || boundary === 'upload' || boundary === 'queue' ? 0 : 1 + ); + } + ); + + it.each(['export', 'upload', 'queue', 'decode'] as const)( + 'interrupts processing at the %s boundary while preserving inputs and the active operation', + async (boundary) => { + const wait = deferred(); + const harness = createHarness({ + decodePreview: + boundary === 'decode' + ? vi.fn(() => wait.promise as Promise) + : vi.fn(({ image }) => Promise.resolve(`decoded:${image.imageName}`)), + exportSource: + boundary === 'export' + ? vi.fn(() => wait.promise as ReturnType['exportSource']>) + : vi.fn(() => Promise.resolve({ blob, guard, rect, status: 'ok' as const })), + runGraph: + boundary === 'queue' + ? vi.fn(() => wait.promise as ReturnType['runGraph']>) + : vi.fn(() => Promise.resolve({ height: 12, imageName: 'result.png', origin: 'test', width: 16 })), + uploadIntermediate: + boundary === 'upload' + ? vi.fn(() => wait.promise as ReturnType['uploadIntermediate']>) + : vi.fn(() => Promise.resolve({ height: 12, imageName: 'input.png', width: 16 })), + }); + harness.session.update({ input: { prompt: 'keep me', type: 'prompt' }, invert: true }); + const pending = harness.session.process(); + await vi.waitFor(() => { + const fn = + boundary === 'export' + ? harness.deps.exportSource + : boundary === 'upload' + ? harness.deps.uploadIntermediate + : boundary === 'queue' + ? harness.deps.runGraph + : harness.deps.decodePreview; + expect(fn).toHaveBeenCalledOnce(); + }); + + harness.session.interruptProcessing(); + if (boundary === 'export') { + wait.resolve({ blob, guard, rect, status: 'ok' }); + } else if (boundary === 'upload') { + wait.resolve({ height: 12, imageName: 'input.png', width: 16 }); + } else if (boundary === 'queue') { + wait.resolve({ height: 12, imageName: 'result.png', origin: 'test', width: 16 }); + } else { + wait.resolve('decoded'); + } + + await expect(pending).resolves.toBe('stale'); + expect(harness.deps.publishPreview).not.toHaveBeenCalled(); + expect(harness.session.getSnapshot()).toMatchObject({ + input: { prompt: 'keep me', type: 'prompt' }, + invert: true, + preview: null, + status: 'ready', + }); + expect(harness.controller.getSnapshot()).toMatchObject({ phase: 'ready', status: 'active' }); + } + ); + + it('suppresses a decoded preview when the source guard becomes stale', async () => { + const decode = deferred(); + const harness = createHarness({ decodePreview: vi.fn(() => decode.promise) }); + const pending = harness.session.process(); + await vi.waitFor(() => expect(harness.deps.decodePreview).toHaveBeenCalledOnce()); + + harness.setCurrentGuard(null); + decode.resolve('decoded'); + + await expect(pending).resolves.toBe('stale'); + expect(harness.deps.publishPreview).not.toHaveBeenCalled(); + expect(harness.session.getSnapshot()).toMatchObject({ preview: null, sourceGuard: null, status: 'ready' }); + }); + + it('reset cancels work and scheduling, clears preview/cache, and restores defaults', async () => { + vi.useFakeTimers(); + const graph = deferred<{ height: number; imageName: string; origin: string; width: number }>(); + const harness = createHarness({ runGraph: vi.fn(() => graph.promise) }); + harness.session.update({ autoProcess: true, invert: true, model: 'segment-anything-huge' }); + const pending = harness.session.process(); + await vi.waitFor(() => expect(harness.deps.runGraph).toHaveBeenCalledOnce()); + + harness.session.reset(); + graph.resolve({ height: 12, imageName: 'late.png', origin: 'test', width: 16 }); + + await expect(pending).resolves.toBe('stale'); + expect(harness.session.getSnapshot()).toEqual({ + applyPolygonRefinement: false, + autoProcess: false, + error: null, + input: { prompt: '', type: 'prompt' }, + invert: false, + isolatedPreview: true, + model: 'segment-anything-2-large', + preview: null, + sourceGuard: null, + status: 'ready', + }); + await vi.runAllTimersAsync(); + expect(harness.deps.runGraph).toHaveBeenCalledOnce(); + }); + + it('dispose clears scheduling and preview, unsubscribes exactly once, and makes methods inert', async () => { + vi.useFakeTimers(); + const harness = createHarness(); + await harness.session.process(); + harness.session.update({ autoProcess: true }); + const listener = vi.fn(); + harness.session.subscribe(listener); + vi.mocked(harness.deps.cleanupPreview).mockClear(); + listener.mockClear(); + + harness.session.dispose(); + const disposedState = harness.session.getSnapshot(); + + expect(disposedState).toMatchObject({ preview: null, sourceGuard: null, status: 'ready' }); + expect(harness.deps.cleanupPreview).toHaveBeenCalledOnce(); + expect(harness.unsubscribe).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + listener.mockClear(); + + harness.fireControllerSubscriber(); + harness.session.update({ model: 'segment-anything-huge' }); + harness.session.reset(); + harness.session.cancel(); + harness.session.dispose(); + const lateListener = vi.fn(); + harness.session.subscribe(lateListener); + + await expect(harness.session.process()).resolves.toBe('stale'); + expect(harness.session.getSnapshot()).toBe(disposedState); + expect(listener).not.toHaveBeenCalled(); + expect(lateListener).not.toHaveBeenCalled(); + expect(harness.unsubscribe).toHaveBeenCalledOnce(); + }); + + it('dispose aborts in-flight processing and suppresses its completion', async () => { + const graph = deferred<{ height: number; imageName: string; origin: string; width: number }>(); + const signals: AbortSignal[] = []; + const harness = createHarness({ + runGraph: vi.fn((options) => { + if (options.signal) { + signals.push(options.signal); + } + return graph.promise; + }), + }); + const pending = harness.session.process(); + await vi.waitFor(() => expect(harness.deps.runGraph).toHaveBeenCalledOnce()); + + harness.session.dispose(); + + expect(signals[0]?.aborted).toBe(true); + graph.resolve({ height: 12, imageName: 'late.png', origin: 'late', width: 16 }); + await expect(pending).resolves.toBe('stale'); + expect(harness.deps.publishPreview).not.toHaveBeenCalled(); + expect(harness.unsubscribe).toHaveBeenCalledOnce(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.ts new file mode 100644 index 00000000000..7fcbb379855 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.ts @@ -0,0 +1,573 @@ +import type { ExportBakedLayerBlobResult, LayerExportGuard } from '@workbench/canvas-engine/engine'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { RunUtilityGraphOptions, UtilityGraphResult } from '@workbench/canvas-operations/backend/utilityQueue'; +import type { + CanvasOperationController, + CanvasOperationRunResult, + CanvasOperationSession, +} from '@workbench/canvas-operations/operationController'; +import type { SamSessionError, SamSessionErrorCode } from '@workbench/canvas-operations/operationTypes'; +import type { SamInput, SamModel } from '@workbench/generation/canvas/samGraph'; + +import { isSamDocumentInputValid } from '@workbench/generation/canvas/samGraph'; + +import type { SelectObjectPreparedSource, SelectObjectReadyResult } from './layerImageResult'; + +import { prepareSelectObjectSource, processSelectObjectSource } from './layerImageResult'; + +export type SelectObjectSessionStatus = + | 'ready' + | 'scheduled' + | 'preparing-source' + | 'uploading' + | 'processing-sam' + | 'rendering-preview' + | 'error'; + +export interface SelectObjectSessionPreview { + data: T; + guard: LayerExportGuard; + image: SelectObjectReadyResult['image']; + inputHash: string; + previewId: number; + isolated: boolean; + rect: Rect; + sourceImageName: string; +} + +export interface SelectObjectSessionState { + input: SamInput; + model: SamModel; + invert: boolean; + applyPolygonRefinement: boolean; + autoProcess: boolean; + isolatedPreview: boolean; + status: SelectObjectSessionStatus; + error: SamSessionError | null; + preview: SelectObjectSessionPreview | null; + sourceGuard: LayerExportGuard | null; +} + +export interface SelectObjectSessionDeps { + captureGuard(): LayerExportGuard | null; + controller: CanvasOperationController; + exportSource(): Promise; + uploadIntermediate(blob: Blob, signal?: AbortSignal): Promise<{ height: number; imageName: string; width: number }>; + runGraph(options: Pick): Promise; + decodePreview(result: SelectObjectReadyResult, signal: AbortSignal): Promise; + publishPreview(preview: SelectObjectSessionPreview): undefined; + cleanupPreview(): void; + isGuardCurrent(guard: LayerExportGuard): boolean; +} + +export interface CreateSelectObjectSessionOptions { + deps: SelectObjectSessionDeps; + operation: CanvasOperationSession; +} + +export type SelectObjectSessionProcessResult = CanvasOperationRunResult | 'blocked' | 'deduped' | 'invalid'; + +export interface SelectObjectSession { + getSnapshot(): SelectObjectSessionState; + subscribe(listener: () => void): () => void; + update( + changes: Partial< + Pick< + SelectObjectSessionState, + 'applyPolygonRefinement' | 'autoProcess' | 'input' | 'invert' | 'isolatedPreview' | 'model' + > + > + ): void; + process(): Promise; + interruptProcessing(): void; + reportError(error: SamSessionError | string): void; + reset(): void; + cancel(): void; + dispose(): void; +} + +const defaultState = (): SelectObjectSessionState => ({ + applyPolygonRefinement: false, + autoProcess: false, + error: null, + input: { prompt: '', type: 'prompt' }, + invert: false, + isolatedPreview: true, + model: 'segment-anything-2-large', + preview: null, + sourceGuard: null, + status: 'ready', +}); + +const stableInputHash = (state: SelectObjectSessionState): string => { + const input = + state.input.type === 'prompt' + ? ['prompt', state.input.prompt.trim()] + : [ + 'visual', + state.input.includePoints.map(({ x, y }) => [x, y]), + state.input.excludePoints.map(({ x, y }) => [x, y]), + state.input.bbox + ? [state.input.bbox.x, state.input.bbox.y, state.input.bbox.width, state.input.bbox.height] + : null, + ]; + return JSON.stringify([input, state.model, state.invert, state.applyPolygonRefinement]); +}; + +const errorDetail = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause)); + +const withDetail = (code: SamSessionErrorCode, detail?: string): SamSessionError => + detail ? { code, detail } : { code }; + +const getPreparedSourceError = ( + result: Exclude>, { status: 'ready' }> +): SamSessionError => { + if (result.status === 'failed') { + return withDetail(result.code, result.message); + } + if (result.status === 'empty') { + return { code: 'empty' }; + } + if (result.status === 'dimension-mismatch') { + return withDetail('output-dimension', result.message); + } + return { code: 'not-ready' }; +}; + +const getProcessedSourceError = ( + result: Exclude>, { status: 'ready' | 'aborted' }> +): SamSessionError => { + if (result.status === 'invalid-input') { + return { code: 'invalid' }; + } + if (result.status === 'dimension-mismatch') { + return withDetail('output-dimension', result.message); + } + if (result.status === 'failed') { + return withDetail(result.code, result.message); + } + return { code: 'unknown' }; +}; + +class SamSessionFailure extends Error { + readonly error: SamSessionError; + + constructor(error: SamSessionError) { + super(error.detail ?? error.code); + this.name = 'SamSessionFailure'; + this.error = error; + } +} + +export const createSelectObjectSession = (options: CreateSelectObjectSessionOptions): SelectObjectSession => { + const { deps, operation } = options; + let state = defaultState(); + let source: SelectObjectPreparedSource | null = null; + let requestToken = 0; + let lastPublishedHash: string | null = null; + let pendingHash: string | null = null; + let pendingPhase: SelectObjectSessionStatus | null = null; + let pendingProcess: Promise | null = null; + let timer: ReturnType | null = null; + let disposed = false; + let unsubscribeController: (() => void) | null = null; + const listeners = new Set<() => void>(); + + const publishState = (next: SelectObjectSessionState): void => { + if (next.status === 'error') { + state = next.error === null ? { ...next, status: 'ready' } : next; + } else { + state = next.error === null ? next : { ...next, error: null }; + } + for (const listener of listeners) { + try { + listener(); + } catch { + // One faulty subscriber must not interrupt session state transitions. + } + } + }; + + const clearTimer = (): void => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + + const publishPhase = (token: number, status: SelectObjectSessionStatus): void => { + if (!disposed && token === requestToken) { + pendingPhase = status; + publishState({ ...state, error: null, status }); + } + }; + + const clearSource = (): void => { + requestToken += 1; + pendingHash = null; + pendingPhase = null; + pendingProcess = null; + source = null; + lastPublishedHash = null; + publishState({ ...state, preview: null, sourceGuard: null, status: 'ready' }); + }; + + const isGuardCurrent = (guard: LayerExportGuard): boolean => { + try { + return deps.isGuardCurrent(guard); + } catch { + return false; + } + }; + + const isSameGuard = (left: LayerExportGuard, right: LayerExportGuard): boolean => + left.projectId === right.projectId && + left.layerId === right.layerId && + left.layer === right.layer && + left.cacheVersion === right.cacheVersion && + left.documentGeneration === right.documentGeneration; + + unsubscribeController = deps.controller.subscribe(() => { + if (!disposed && deps.controller.getSnapshot().status === 'idle') { + clearSource(); + } + }); + + const schedule = (): void => { + clearTimer(); + if (disposed) { + return; + } + if (!state.autoProcess || !isSamDocumentInputValid(state.input)) { + if (state.status === 'scheduled') { + publishState({ ...state, status: 'ready' }); + } + return; + } + publishState({ ...state, error: null, status: 'scheduled' }); + timer = setTimeout(() => { + timer = null; + void process(); + }, 1_000); + }; + + const ensureSource = async ( + guard: LayerExportGuard, + signal: AbortSignal, + token: number + ): Promise => { + if (source && isSameGuard(source.guard, guard) && isGuardCurrent(source.guard) && isGuardCurrent(guard)) { + source = { ...source, guard }; + return source; + } + source = null; + const prepared = await prepareSelectObjectSource(deps, signal, (phase) => publishPhase(token, phase)); + if (prepared.status !== 'ready') { + throw new SamSessionFailure(getPreparedSourceError(prepared)); + } + if (signal.aborted || !isGuardCurrent(guard) || !isSameGuard(prepared.source.guard, guard)) { + throw new DOMException('Select Object source became stale.', 'AbortError'); + } + source = { ...prepared.source, guard }; + return source; + }; + + const runRequest = async ( + token: number, + hash: string, + requestState: SelectObjectSessionState, + guard: LayerExportGuard + ): Promise => { + let requestError: SamSessionError | null = null; + try { + pendingPhase = 'preparing-source'; + publishState({ ...state, sourceGuard: guard, status: 'preparing-source' }); + const result = await operation.run( + async (signal) => { + try { + const preparedSource = await ensureSource(guard, signal, token); + if (signal.aborted || token !== requestToken) { + throw new DOMException('Select Object source preparation was aborted.', 'AbortError'); + } + const processed = await processSelectObjectSource({ + applyPolygonRefinement: requestState.applyPolygonRefinement, + input: requestState.input, + invert: requestState.invert, + model: requestState.model, + runGraph: deps.runGraph, + onPhase: (phase) => publishPhase(token, phase), + signal, + source: preparedSource, + }); + if (processed.status !== 'ready') { + if (processed.status === 'aborted') { + throw new DOMException('Select Object processing was aborted.', 'AbortError'); + } + throw new SamSessionFailure(getProcessedSourceError(processed)); + } + if (!isGuardCurrent(guard) || !isSameGuard(processed.guard, guard)) { + throw new DOMException('Select Object layer source became stale.', 'AbortError'); + } + publishPhase(token, 'rendering-preview'); + let data: T; + try { + data = await deps.decodePreview(processed, signal); + } catch (cause) { + if (signal.aborted || (cause instanceof Error && cause.name === 'AbortError')) { + throw cause; + } + const code = + cause && + typeof cause === 'object' && + 'samErrorCode' in cause && + cause.samErrorCode === 'output-dimension' + ? 'output-dimension' + : 'decode'; + throw new SamSessionFailure(withDetail(code, errorDetail(cause))); + } + if (signal.aborted || !isGuardCurrent(guard)) { + throw new DOMException('Select Object preview decode was aborted.', 'AbortError'); + } + return { + data, + guard: processed.guard, + image: processed.image, + inputHash: hash, + previewId: token, + isolated: requestState.isolatedPreview, + rect: processed.rect, + sourceImageName: preparedSource.imageName, + } satisfies SelectObjectSessionPreview; + } catch (cause) { + if (cause instanceof SamSessionFailure) { + requestError = cause.error; + } + throw cause; + } + }, + (preview) => { + if (!isGuardCurrent(preview.guard)) { + throw new DOMException('Select Object preview became stale.', 'AbortError'); + } + const publishedPreview = + preview.isolated === state.isolatedPreview ? preview : { ...preview, isolated: state.isolatedPreview }; + deps.publishPreview(publishedPreview); + lastPublishedHash = hash; + publishState({ + ...state, + error: null, + preview: publishedPreview, + sourceGuard: preview.guard, + status: 'ready', + }); + return undefined; + } + ); + if (token !== requestToken) { + return 'stale'; + } + if (result === 'error' && requestError === null && !isGuardCurrent(guard)) { + clearSource(); + return 'stale'; + } + if (result === 'error') { + publishState({ + ...state, + error: requestError ?? withDetail('unknown', 'Select Object processing failed.'), + status: 'error', + }); + } else if (result === 'stale' && !isGuardCurrent(guard)) { + clearSource(); + } + return result; + } catch (cause) { + if (token !== requestToken || (cause instanceof Error && cause.name === 'AbortError')) { + return 'stale'; + } + publishState({ + ...state, + error: cause instanceof SamSessionFailure ? cause.error : withDetail('unknown', errorDetail(cause)), + status: 'error', + }); + return 'error'; + } + }; + + const process = (): Promise => { + clearTimer(); + if (disposed) { + return Promise.resolve('stale'); + } + if (!isSamDocumentInputValid(state.input)) { + publishState({ ...state, error: { code: 'invalid' }, status: 'error' }); + return Promise.resolve('invalid'); + } + if (state.sourceGuard && !isGuardCurrent(state.sourceGuard)) { + requestToken += 1; + operation.interruptProcessing(); + source = null; + pendingHash = null; + pendingProcess = null; + lastPublishedHash = null; + publishState({ ...state, preview: null, sourceGuard: null, status: 'ready' }); + } + const hash = stableInputHash(state); + if (hash === pendingHash && pendingProcess) { + publishState({ ...state, error: null, status: pendingPhase ?? 'preparing-source' }); + return pendingProcess; + } + if ( + hash === lastPublishedHash && + state.preview !== null && + state.sourceGuard === state.preview.guard && + source?.guard === state.preview.guard && + isGuardCurrent(state.preview.guard) + ) { + publishState({ ...state, error: null, status: 'ready' }); + return Promise.resolve('deduped'); + } + + requestToken += 1; + const token = requestToken; + const guard = deps.captureGuard(); + if (!guard || !isGuardCurrent(guard)) { + publishState({ ...state, error: { code: 'not-ready' }, status: 'error' }); + return Promise.resolve('error'); + } + pendingHash = hash; + const requestState = state; + const promise = runRequest(token, hash, requestState, guard).finally(() => { + if (token === requestToken) { + pendingHash = null; + pendingPhase = null; + pendingProcess = null; + } + }); + pendingProcess = promise; + return promise; + }; + + const cancelCurrent = (): void => { + clearTimer(); + requestToken += 1; + pendingHash = null; + pendingPhase = null; + pendingProcess = null; + operation.cancel(); + source = null; + lastPublishedHash = null; + }; + + const invalidateProcessingState = (): void => { + requestToken += 1; + pendingHash = null; + pendingPhase = null; + pendingProcess = null; + lastPublishedHash = null; + operation.reset(); + }; + + return { + cancel: () => { + if (disposed) { + return; + } + cancelCurrent(); + publishState({ ...state, error: null, preview: null, sourceGuard: null, status: 'ready' }); + }, + dispose: () => { + if (disposed) { + return; + } + disposed = true; + clearTimer(); + requestToken += 1; + pendingHash = null; + pendingPhase = null; + pendingProcess = null; + lastPublishedHash = null; + unsubscribeController?.(); + unsubscribeController = null; + operation.cancel(); + source = null; + publishState({ ...state, error: null, preview: null, sourceGuard: null, status: 'ready' }); + listeners.clear(); + }, + getSnapshot: () => state, + interruptProcessing: () => { + clearTimer(); + if (disposed || state.status === 'ready' || state.status === 'error') { + return; + } + requestToken += 1; + pendingHash = null; + pendingPhase = null; + pendingProcess = null; + lastPublishedHash = null; + operation.interruptProcessing(); + publishState({ ...state, error: null, preview: null, status: 'ready' }); + }, + process, + reportError: (error) => { + if (!disposed) { + publishState({ + ...state, + error: typeof error === 'string' ? withDetail('unknown', error) : error, + status: 'error', + }); + } + }, + reset: () => { + if (disposed) { + return; + } + clearTimer(); + requestToken += 1; + pendingHash = null; + pendingPhase = null; + pendingProcess = null; + operation.reset(); + source = null; + lastPublishedHash = null; + publishState(defaultState()); + }, + subscribe: (listener) => { + if (disposed) { + return () => undefined; + } + listeners.add(listener); + return () => listeners.delete(listener); + }, + update: (changes) => { + if (disposed) { + return; + } + const processingChanged = + ('input' in changes && changes.input !== state.input) || + ('model' in changes && changes.model !== state.model) || + ('invert' in changes && changes.invert !== state.invert) || + ('applyPolygonRefinement' in changes && changes.applyPolygonRefinement !== state.applyPolygonRefinement); + const isolationChanged = 'isolatedPreview' in changes && changes.isolatedPreview !== state.isolatedPreview; + if (processingChanged) { + invalidateProcessingState(); + } + const preview = + isolationChanged && state.preview + ? { ...state.preview, isolated: changes.isolatedPreview ?? state.isolatedPreview } + : state.preview; + if (preview && preview !== state.preview) { + deps.publishPreview(preview); + } + publishState({ + ...state, + ...changes, + error: null, + preview: processingChanged ? null : preview, + status: processingChanged ? 'ready' : state.status, + }); + if (processingChanged || 'autoProcess' in changes) { + schedule(); + } + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts new file mode 100644 index 00000000000..6f1fbb871af --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts @@ -0,0 +1,379 @@ +import { describe, expect, it } from 'vitest'; + +import type { CanvasProjectMutation } from './canvasProjectMutations'; +import type { MainModelConfig } from './generation/types'; +import type { WorkbenchState } from './types'; +import type { WorkbenchAction } from './workbenchState'; + +import { type CanvasDimsSnapshot, createCanvasDimsSync, reconcileCanvasDims } from './canvasDimsSync'; +import { getProjectWidgetValues } from './widgetState'; +import { createWorkbenchStore } from './workbenchStore'; + +const bbox = (width: number, height: number, x = 0, y = 0) => ({ height, width, x, y }); + +const dispatchCanvas = (store: ReturnType, mutation: CanvasProjectMutation): void => { + store.dispatch({ mutation, projectId: store.getState().activeProjectId, type: 'applyCanvasProjectMutation' }); +}; + +const snapshot = (bboxW: number, bboxH: number, dimsW: number, dimsH: number): CanvasDimsSnapshot => ({ + bboxHeight: bboxH, + bboxWidth: bboxW, + dimsHeight: dimsH, + dimsWidth: dimsW, +}); + +describe('reconcileCanvasDims (pure)', () => { + it('no-ops when there is no canvas frame (not in canvas mode)', () => { + expect(reconcileCanvasDims({ bbox: null, dims: { height: 512, width: 512 }, grid: 8, prev: null })).toEqual({ + kind: 'none', + }); + }); + + it('no-ops when the bbox and dims already agree', () => { + expect( + reconcileCanvasDims({ + bbox: bbox(1024, 1024), + dims: { height: 1024, width: 1024 }, + grid: 8, + prev: snapshot(1024, 1024, 1024, 1024), + }) + ).toEqual({ kind: 'none' }); + }); + + it('maps a bbox size change onto the generate dims and re-derives the aspect id', () => { + const result = reconcileCanvasDims({ + bbox: bbox(512, 768), + dims: { height: 1024, width: 1024 }, + grid: 8, + prev: snapshot(1024, 1024, 1024, 1024), + }); + + expect(result).toEqual({ + aspectRatioId: '2:3', + aspectRatioValue: 512 / 768, + height: 768, + kind: 'patch-dims', + width: 512, + }); + }); + + it('emits the bbox ratio as aspectRatioValue for a non-square bbox (not just the preset id)', () => { + const result = reconcileCanvasDims({ + bbox: bbox(1024, 768), + dims: { height: 1024, width: 1024 }, + grid: 8, + prev: snapshot(1024, 1024, 1024, 1024), + }); + + // 4:3 as an id, but the numeric ratio must agree so downstream constraint + // math (GenerateDimensionFields' getActiveRatio) uses the fresh ratio + // instead of a stale 1.0 left over from the previous (square) dims. + expect(result).toMatchObject({ aspectRatioId: '4:3', aspectRatioValue: 1024 / 768, kind: 'patch-dims' }); + }); + + it('re-derives a preset aspect id for a matching bbox ratio', () => { + const result = reconcileCanvasDims({ + bbox: bbox(1024, 768), + dims: { height: 1024, width: 1024 }, + grid: 8, + prev: snapshot(1024, 1024, 1024, 1024), + }); + + expect(result).toMatchObject({ aspectRatioId: '4:3', kind: 'patch-dims' }); + }); + + it('re-derives Free for a non-preset bbox ratio', () => { + const result = reconcileCanvasDims({ + bbox: bbox(1000, 512), + dims: { height: 1024, width: 1024 }, + grid: 8, + prev: snapshot(1024, 1024, 1024, 1024), + }); + + expect(result).toMatchObject({ aspectRatioId: 'Free', kind: 'patch-dims' }); + }); + + it('ignores a position-only bbox move (same width/height)', () => { + const result = reconcileCanvasDims({ + bbox: bbox(1024, 1024, 128, 64), + dims: { height: 1024, width: 1024 }, + grid: 8, + prev: snapshot(1024, 1024, 1024, 1024), + }); + + expect(result).toEqual({ kind: 'none' }); + }); + + it('resizes the bbox to changed dims, keeping the top-left position', () => { + const result = reconcileCanvasDims({ + bbox: bbox(512, 768, 40, 24), + dims: { height: 768, width: 768 }, + grid: 8, + prev: snapshot(512, 768, 512, 768), + }); + + expect(result).toEqual({ bbox: { height: 768, width: 768, x: 40, y: 24 }, kind: 'set-bbox' }); + }); + + it('snaps dims to the grid when resizing the bbox', () => { + const result = reconcileCanvasDims({ + bbox: bbox(512, 512), + dims: { height: 100, width: 100 }, + grid: 16, + prev: snapshot(512, 512, 512, 512), + }); + + expect(result).toEqual({ bbox: { height: 96, width: 96, x: 0, y: 0 }, kind: 'set-bbox' }); + }); + + it('clamps a sub-grid dimension up to the grid minimum', () => { + const result = reconcileCanvasDims({ + bbox: bbox(512, 512), + dims: { height: 1, width: 1 }, + grid: 16, + prev: snapshot(512, 512, 512, 512), + }); + + expect(result).toEqual({ bbox: { height: 16, width: 16, x: 0, y: 0 }, kind: 'set-bbox' }); + }); + + it('no-ops the dims->bbox direction when the snapped size already matches the bbox', () => { + const result = reconcileCanvasDims({ + bbox: bbox(96, 96), + dims: { height: 100, width: 100 }, + grid: 16, + prev: snapshot(96, 96, 96, 96), + }); + + expect(result).toEqual({ kind: 'none' }); + }); + + it('lets the bbox win on first run (no prior snapshot)', () => { + const result = reconcileCanvasDims({ + bbox: bbox(512, 512), + dims: { height: 256, width: 256 }, + grid: 8, + prev: null, + }); + + expect(result).toEqual({ aspectRatioId: '1:1', aspectRatioValue: 1, height: 512, kind: 'patch-dims', width: 512 }); + }); + + it('converges: applying each direction twice is a fixed point', () => { + // bbox -> dims, then re-reconcile the resulting agreed state. + const first = reconcileCanvasDims({ + bbox: bbox(640, 512), + dims: { height: 1024, width: 1024 }, + grid: 8, + prev: snapshot(1024, 1024, 1024, 1024), + }); + expect(first.kind).toBe('patch-dims'); + const afterPatch = reconcileCanvasDims({ + bbox: bbox(640, 512), + dims: { height: 512, width: 640 }, + grid: 8, + prev: snapshot(640, 512, 640, 512), + }); + expect(afterPatch).toEqual({ kind: 'none' }); + + // dims -> bbox, then re-reconcile the resulting agreed state. + const second = reconcileCanvasDims({ + bbox: bbox(512, 512, 10, 20), + dims: { height: 512, width: 768 }, + grid: 8, + prev: snapshot(512, 512, 512, 512), + }); + expect(second.kind).toBe('set-bbox'); + const afterResize = reconcileCanvasDims({ + bbox: bbox(768, 512, 10, 20), + dims: { height: 512, width: 768 }, + grid: 8, + prev: snapshot(768, 512, 768, 512), + }); + expect(afterResize).toEqual({ kind: 'none' }); + }); +}); + +const model: MainModelConfig = { base: 'sdxl', key: 'test-model', name: 'Test Model', type: 'main' }; + +const getActiveGenerate = (state: WorkbenchState) => { + const project = state.projects.find((candidate) => candidate.id === state.activeProjectId); + if (!project) { + throw new Error('no active project'); + } + return { bbox: project.canvas.document.bbox, values: getProjectWidgetValues(project, 'generate') }; +}; + +/** A store that counts only the dispatches the sync itself issues. */ +const setupCanvasStore = () => { + const store = createWorkbenchStore(); + + // Put the active project into canvas mode with concrete generate dims/model. + store.dispatch({ type: 'setInvocationSource', sourceId: 'canvas' }); + store.dispatch({ + type: 'patchGenerateSettings', + values: { height: 1024, model, modelKey: model.key, width: 1024 }, + }); + + let syncDispatches = 0; + const countingStore = { + dispatch: (action: WorkbenchAction) => { + syncDispatches += 1; + store.dispatch(action); + }, + getState: store.getState, + subscribe: store.subscribe, + }; + + const sync = createCanvasDimsSync(countingStore); + + return { getSyncDispatches: () => syncDispatches, store, sync }; +}; + +describe('createCanvasDimsSync (wiring)', () => { + it('does not dispatch on mount when bbox and dims already agree', () => { + const { getSyncDispatches, sync } = setupCanvasStore(); + + expect(getSyncDispatches()).toBe(0); + sync.dispose(); + }); + + it('patches the generate dims when the bbox changes, without looping', () => { + const { getSyncDispatches, store, sync } = setupCanvasStore(); + + dispatchCanvas(store, { bbox: { height: 768, width: 512, x: 0, y: 0 }, type: 'setCanvasBbox' }); + + const { values } = getActiveGenerate(store.getState()); + expect(values.width).toBe(512); + expect(values.height).toBe(768); + expect(values.aspectRatioId).toBe('2:3'); + // The numeric ratio must be re-derived alongside the id, or downstream + // constraint math (GenerateDimensionFields' getActiveRatio, which prefers + // aspectRatioValue whenever it is > 0) keeps constraining edits to the + // stale ratio from before the bbox drag. + expect(values.aspectRatioValue).toBeCloseTo(512 / 768); + // Exactly one sync dispatch (the echo is a no-op, so no unbounded loop). + expect(getSyncDispatches()).toBe(1); + sync.dispose(); + }); + + it('a bbox-driven ratio is what a subsequent constrained width edit uses (not the pre-drag ratio)', () => { + const { store, sync } = setupCanvasStore(); + + // Drag the bbox to a non-square 4:3 frame; dims should follow. + dispatchCanvas(store, { bbox: { height: 768, width: 1024, x: 0, y: 0 }, type: 'setCanvasBbox' }); + const { values } = getActiveGenerate(store.getState()); + expect(values.aspectRatioId).toBe('4:3'); + expect(values.aspectRatioValue).toBeCloseTo(1024 / 768); + + const aspectRatioValue = values.aspectRatioValue as number; + const width = values.width as number; + const height = values.height as number; + + // Mirror GenerateDimensionFields' getActiveRatio: prefer aspectRatioValue + // when positive, else fall back to the live width/height. + const activeRatio = aspectRatioValue > 0 ? aspectRatioValue : width / height; + const nextWidth = 800; + const constrainedHeight = nextWidth / activeRatio; + + // Before the fix this would divide by the stale ratio (1.0, from the + // original 1024x1024 dims) and yield the wrong height (800 instead of 600). + expect(constrainedHeight).toBeCloseTo(600); + sync.dispose(); + }); + + it('resizes the bbox when the generate dims change, without looping', () => { + const { getSyncDispatches, store, sync } = setupCanvasStore(); + + // Move the frame first so the resize must preserve the top-left position. + dispatchCanvas(store, { bbox: { height: 1024, width: 1024, x: 32, y: 48 }, type: 'setCanvasBbox' }); + const before = getSyncDispatches(); + + store.dispatch({ type: 'patchGenerateSettings', values: { width: 512 } }); + + const { bbox: nextBbox } = getActiveGenerate(store.getState()); + expect(nextBbox).toEqual({ height: 1024, width: 512, x: 32, y: 48 }); + expect(getSyncDispatches() - before).toBe(1); + sync.dispose(); + }); + + it('driving the dims off-grid still keeps the dispatch count bounded (no snap/reconcile loop)', () => { + const { getSyncDispatches, store, sync } = setupCanvasStore(); + + // Move the frame first so the resize must preserve the top-left position. + dispatchCanvas(store, { bbox: { height: 1024, width: 1024, x: 32, y: 48 }, type: 'setCanvasBbox' }); + const before = getSyncDispatches(); + + // 501 is not a multiple of the sdxl grid (8): dims -> bbox must snap it, + // and the bbox -> dims echo from that snapped bbox must not re-trigger + // another round of reconciliation (the snapped bbox and the still-501-wide + // dims disagree by construction, but the sync's re-entrancy guard must + // still hold the total dispatch count for this one external change to 1). + store.dispatch({ type: 'patchGenerateSettings', values: { width: 501 } }); + + const { bbox: nextBbox } = getActiveGenerate(store.getState()); + expect(nextBbox.width).toBe(504); // snapped up to the nearest multiple of 8 + expect(getSyncDispatches() - before).toBe(1); + sync.dispose(); + }); + + it('ignores a position-only bbox move', () => { + const { getSyncDispatches, store, sync } = setupCanvasStore(); + + dispatchCanvas(store, { bbox: { height: 1024, width: 1024, x: 100, y: 200 }, type: 'setCanvasBbox' }); + + const { values } = getActiveGenerate(store.getState()); + expect(values.width).toBe(1024); + expect(values.height).toBe(1024); + expect(getSyncDispatches()).toBe(0); + sync.dispose(); + }); + + it('stays inert when the project is not invoking into the canvas', () => { + const store = createWorkbenchStore(); + store.dispatch({ type: 'setInvocationSource', sourceId: 'generate' }); + store.dispatch({ + type: 'patchGenerateSettings', + values: { height: 1024, model, modelKey: model.key, width: 1024 }, + }); + + let syncDispatches = 0; + const countingStore = { + dispatch: (action: WorkbenchAction) => { + syncDispatches += 1; + store.dispatch(action); + }, + getState: store.getState, + subscribe: store.subscribe, + }; + const sync = createCanvasDimsSync(countingStore); + + dispatchCanvas(store, { bbox: { height: 512, width: 512, x: 0, y: 0 }, type: 'setCanvasBbox' }); + + const { values } = getActiveGenerate(store.getState()); + // Generate dims untouched: non-canvas behavior is exactly as today. + expect(values.width).toBe(1024); + expect(values.height).toBe(1024); + expect(syncDispatches).toBe(0); + sync.dispose(); + }); + + it('aligns dims to the bbox when a project enters canvas mode', () => { + const store = createWorkbenchStore(); + // Diverge the frame from the dims while still in generate mode. + store.dispatch({ + type: 'patchGenerateSettings', + values: { height: 1024, model, modelKey: model.key, width: 1024 }, + }); + dispatchCanvas(store, { bbox: { height: 640, width: 896, x: 0, y: 0 }, type: 'setCanvasBbox' }); + + const sync = createCanvasDimsSync(store); + // Switching into canvas mode should let the bbox win and drive the dims. + store.dispatch({ type: 'setInvocationSource', sourceId: 'canvas' }); + + const { values } = getActiveGenerate(store.getState()); + expect(values.width).toBe(896); + expect(values.height).toBe(640); + sync.dispose(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts new file mode 100644 index 00000000000..3841e93dedc --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts @@ -0,0 +1,261 @@ +/** + * Two-way sync between a project's canvas generation frame (`canvas.document.bbox`) + * and its generate-widget dimensions (`width` / `height` / `aspectRatioId`). + * + * Legacy parity: while a project is invoking into the canvas, the generation + * frame IS the generation size. Resizing the bbox (tool gesture, BboxOptions, + * undo/redo) drives the generate width/height; editing the generate dimensions + * (or picking an aspect preset) resizes the bbox in place (top-left anchored). + * Position-only bbox moves never touch the dimensions. + * + * The module is two parts: + * - {@link reconcileCanvasDims}: a pure, unit-tested reconcile that decides which + * side (if any) to write, given the current bbox, the current committed + * generate dims, the snapping grid, and the last-synced snapshot. + * - {@link createCanvasDimsSync}: a thin `store.subscribe` wiring that feeds the + * reconcile from workbench state and dispatches the resulting action. + * + * Loop safety: the reconcile short-circuits to `none` whenever the bbox and dims + * already agree, so applying either direction is a fixed point. The wiring also + * updates its last-synced snapshot *before* dispatching and hard-guards against + * re-entrant notifications, keeping the dispatch count per external change + * bounded (at most one echo, which is itself a no-op). + * + * Only active while `project.invocation.sourceId === 'canvas'`; for every other + * source the sync is inert and generate-dimension editing behaves exactly as it + * does today. Zero React. + */ + +import type { AspectRatioId } from './generation/types'; +import type { CanvasDocumentContractV2, WorkbenchState } from './types'; +import type { WorkbenchAction } from './workbenchState'; + +import { deriveAspectRatioId } from './generation/settings'; +import { gridSizeForModelBase } from './widgets/canvas/bboxGrid'; +import { getProjectWidgetValues } from './widgetState'; + +type Bbox = CanvasDocumentContractV2['bbox']; + +/** The last-synced width/height on both sides, used to detect which side changed. */ +export interface CanvasDimsSnapshot { + bboxWidth: number; + bboxHeight: number; + dimsWidth: number; + dimsHeight: number; +} + +export interface CanvasDimsReconcileInput { + /** The current generation frame, or `null` when the sync should stay inert (no canvas mode). */ + bbox: Bbox | null; + /** The current committed generate width/height. */ + dims: { width: number; height: number }; + /** The bbox/generate snapping grid (model-derived; identical on both sides). */ + grid: number; + /** The last snapshot this sync wrote/observed, or `null` on first run / after a reset. */ + prev: CanvasDimsSnapshot | null; +} + +export type CanvasDimsReconcileResult = + | { kind: 'none' } + /** + * Write the bbox size onto the generate dims (bbox wins), re-deriving the + * aspect id *and* the numeric aspect ratio. Both are re-derived from the bbox + * unconditionally (even when the form's ratio is locked to a preset) so the + * id, the numeric ratio, and the dims stay mutually consistent after the + * patch — a locked preset does not veto the bbox, which remains authoritative. + */ + | { kind: 'patch-dims'; width: number; height: number; aspectRatioId: AspectRatioId; aspectRatioValue: number } + /** Resize the bbox to the (grid-snapped) generate dims, keeping its top-left position. */ + | { kind: 'set-bbox'; bbox: Bbox }; + +const snapToGrid = (value: number, grid: number): number => { + const step = grid > 0 ? grid : 1; + return Math.max(step, Math.round(value / step) * step); +}; + +/** + * Decide which direction of the bbox <-> dims sync to apply. + * + * - No bbox (not in canvas mode) -> `none`. + * - Bbox and dims already agree -> `none` (the primary loop guard). + * - Otherwise the side that changed since `prev` wins; the bbox is authoritative + * when both (or neither, on first run) changed, matching "the frame is the + * generation size". Bbox -> dims writes the exact bbox size and re-derives the + * aspect id. Dims -> bbox snaps to the grid and only emits when the snapped + * size actually differs from the live bbox. + */ +export const reconcileCanvasDims = ({ + bbox, + dims, + grid, + prev, +}: CanvasDimsReconcileInput): CanvasDimsReconcileResult => { + if (!bbox) { + return { kind: 'none' }; + } + + if (bbox.width === dims.width && bbox.height === dims.height) { + return { kind: 'none' }; + } + + const bboxChanged = !prev || prev.bboxWidth !== bbox.width || prev.bboxHeight !== bbox.height; + + if (bboxChanged) { + return { + aspectRatioId: deriveAspectRatioId(bbox.width, bbox.height), + aspectRatioValue: bbox.height > 0 ? bbox.width / bbox.height : 1, + height: bbox.height, + kind: 'patch-dims', + width: bbox.width, + }; + } + + const dimsChanged = !prev || prev.dimsWidth !== dims.width || prev.dimsHeight !== dims.height; + + if (dimsChanged) { + const width = snapToGrid(dims.width, grid); + const height = snapToGrid(dims.height, grid); + + if (width === bbox.width && height === bbox.height) { + return { kind: 'none' }; + } + + return { bbox: { height, width, x: bbox.x, y: bbox.y }, kind: 'set-bbox' }; + } + + return { kind: 'none' }; +}; + +/** The minimal workbench store surface the sync depends on. */ +export interface CanvasDimsSyncStore { + getState(): WorkbenchState; + subscribe(listener: () => void): () => void; + dispatch(action: WorkbenchAction): void; +} + +export interface CanvasDimsSync { + dispose(): void; +} + +const readFiniteDimension = (values: Record, key: 'width' | 'height'): number | null => { + const raw = values[key]; + return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : null; +}; + +const readModelBase = (values: Record): string | null => { + const model = values.model; + return model && typeof model === 'object' && typeof (model as { base?: unknown }).base === 'string' + ? (model as { base: string }).base + : null; +}; + +/** + * Wire the bbox <-> generate-dims reconcile onto a workbench store. Subscribes + * immediately; dispatches `patchGenerateSettings` / `setCanvasBbox` as the + * reconcile directs. Returns a handle whose `dispose` removes the subscription. + */ +export const createCanvasDimsSync = (store: CanvasDimsSyncStore): CanvasDimsSync => { + let prev: CanvasDimsSnapshot | null = null; + let lastProjectId: string | null = null; + let isSyncing = false; + + const handleChange = (): void => { + // A dispatch below re-enters this listener synchronously; the snapshot is + // already updated to the post-dispatch expectation, so the nested pass would + // be a no-op — skip it to keep the dispatch count strictly bounded. + if (isSyncing) { + return; + } + + const state = store.getState(); + const project = state.projects.find((candidate) => candidate.id === state.activeProjectId); + + if (!project) { + prev = null; + lastProjectId = null; + return; + } + + if (project.id !== lastProjectId) { + lastProjectId = project.id; + prev = null; + } + + // Inert unless the project is invoking into the canvas: for every other + // source the generate dimensions behave exactly as they do today. + if (project.invocation.sourceId !== 'canvas') { + prev = null; + return; + } + + const generateValues = getProjectWidgetValues(project, 'generate'); + const width = readFiniteDimension(generateValues, 'width'); + const height = readFiniteDimension(generateValues, 'height'); + + if (width === null || height === null) { + prev = null; + return; + } + + const bbox = project.canvas.document.bbox; + const grid = gridSizeForModelBase(readModelBase(generateValues)); + const result = reconcileCanvasDims({ bbox, dims: { height, width }, grid, prev }); + + switch (result.kind) { + case 'none': { + prev = { bboxHeight: bbox.height, bboxWidth: bbox.width, dimsHeight: height, dimsWidth: width }; + return; + } + case 'patch-dims': { + prev = { + bboxHeight: bbox.height, + bboxWidth: bbox.width, + dimsHeight: result.height, + dimsWidth: result.width, + }; + isSyncing = true; + try { + store.dispatch({ + projectId: project.id, + type: 'patchGenerateSettings', + values: { + aspectRatioId: result.aspectRatioId, + aspectRatioValue: result.aspectRatioValue, + height: result.height, + width: result.width, + }, + }); + } finally { + isSyncing = false; + } + return; + } + case 'set-bbox': { + prev = { + bboxHeight: result.bbox.height, + bboxWidth: result.bbox.width, + dimsHeight: height, + dimsWidth: width, + }; + isSyncing = true; + try { + store.dispatch({ + mutation: { bbox: result.bbox, type: 'setCanvasBbox' }, + projectId: project.id, + type: 'applyCanvasProjectMutation', + }); + } finally { + isSyncing = false; + } + return; + } + } + }; + + const unsubscribe = store.subscribe(handleChange); + + // Seed from the current state so an already-canvas project reconciles on mount. + handleChange(); + + return { dispose: unsubscribe }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvasLayerOps.test.ts b/invokeai/frontend/webv2/src/workbench/canvasLayerOps.test.ts new file mode 100644 index 00000000000..3f3f69da9ec --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasLayerOps.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import type { CanvasLayerContract } from './types'; + +import { + deleteLayerActions, + duplicateLayerActions, + reorderIdsForHotkey, + reorderLayerActions, + reorderTargetIndex, +} from './canvasLayerOps'; + +const layer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +describe('duplicateLayerActions', () => { + it('duplicates forward and removes the duplicate on undo', () => { + expect(duplicateLayerActions('a', 'a-copy')).toEqual({ + forward: { newId: 'a-copy', sourceId: 'a', type: 'duplicateCanvasLayer' }, + inverse: { ids: ['a-copy'], type: 'removeCanvasLayers' }, + }); + }); +}); + +describe('deleteLayerActions', () => { + it('removes forward and re-adds at the original index on undo', () => { + const l = layer('a'); + expect(deleteLayerActions(l, 2)).toEqual({ + forward: { ids: ['a'], type: 'removeCanvasLayers' }, + inverse: { index: 2, layer: l, type: 'addCanvasLayer' }, + }); + }); +}); + +describe('reorderLayerActions', () => { + it('reorders forward and restores the prior order on undo (copies arrays)', () => { + const current = ['a', 'b', 'c']; + const next = ['b', 'a', 'c']; + const actions = reorderLayerActions(current, next); + expect(actions).toEqual({ + forward: { orderedIds: ['b', 'a', 'c'], type: 'reorderCanvasLayers' }, + inverse: { orderedIds: ['a', 'b', 'c'], type: 'reorderCanvasLayers' }, + }); + // Snapshotted, not aliased. + if (actions.forward.type === 'reorderCanvasLayers') { + expect(actions.forward.orderedIds).not.toBe(next); + } + }); +}); + +describe('reorderTargetIndex (index 0 = top-most)', () => { + it('moves toward/away and clamps at boundaries', () => { + expect(reorderTargetIndex(2, 5, 'forward')).toBe(1); + expect(reorderTargetIndex(0, 5, 'forward')).toBe(0); + expect(reorderTargetIndex(2, 5, 'backward')).toBe(3); + expect(reorderTargetIndex(4, 5, 'backward')).toBe(4); + expect(reorderTargetIndex(3, 5, 'front')).toBe(0); + expect(reorderTargetIndex(1, 5, 'back')).toBe(4); + }); +}); + +describe('reorderIdsForHotkey', () => { + it('moves one step toward the front', () => { + expect(reorderIdsForHotkey(['a', 'b', 'c'], 2, 'forward')).toEqual(['a', 'c', 'b']); + }); + + it('moves one step toward the back', () => { + expect(reorderIdsForHotkey(['a', 'b', 'c'], 0, 'backward')).toEqual(['b', 'a', 'c']); + }); + + it('moves to front and to back', () => { + expect(reorderIdsForHotkey(['a', 'b', 'c'], 2, 'front')).toEqual(['c', 'a', 'b']); + expect(reorderIdsForHotkey(['a', 'b', 'c'], 0, 'back')).toEqual(['b', 'c', 'a']); + }); + + it('returns null when nothing would move (already at the boundary)', () => { + expect(reorderIdsForHotkey(['a', 'b', 'c'], 0, 'forward')).toBeNull(); + expect(reorderIdsForHotkey(['a', 'b', 'c'], 2, 'backward')).toBeNull(); + expect(reorderIdsForHotkey(['a', 'b', 'c'], 0, 'front')).toBeNull(); + expect(reorderIdsForHotkey(['a', 'b', 'c'], 2, 'back')).toBeNull(); + }); + + it('returns null for an out-of-range index', () => { + expect(reorderIdsForHotkey(['a', 'b'], 5, 'forward')).toBeNull(); + expect(reorderIdsForHotkey(['a', 'b'], -1, 'forward')).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvasLayerOps.ts b/invokeai/frontend/webv2/src/workbench/canvasLayerOps.ts new file mode 100644 index 00000000000..8306ec8bc0a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasLayerOps.ts @@ -0,0 +1,88 @@ +/** + * Pure builders for the structural document edits shared by the layers panel and + * the canvas widget's layer hotkeys: each returns the forward + inverse reducer + * action pair for `engine.layers.commitStructural` / `applyStructural`, so the + * inverse-construction logic lives in exactly one place. + * + * Index convention matches the contract and the layers panel: index 0 is the + * top-most layer, so "up"/"forward" moves toward index 0. + * + * Zero React, zero import-time side effects. + */ + +import type { CanvasProjectMutation } from './canvasProjectMutations'; +import type { CanvasLayerContract } from './types'; + +/** A forward/inverse reducer-action pair for one reversible structural edit. */ +export interface StructuralActions { + forward: CanvasProjectMutation; + inverse: CanvasProjectMutation; +} + +/** Mints a fresh layer id (matches the engine's / layers panel's id shape). */ +export const createLayerId = (): string => `layer-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +/** Duplicate a layer (forward), removing the duplicate on undo (inverse). */ +export const duplicateLayerActions = (sourceId: string, newId: string): StructuralActions => ({ + forward: { newId, sourceId, type: 'duplicateCanvasLayer' }, + inverse: { ids: [newId], type: 'removeCanvasLayers' }, +}); + +/** Delete a layer (forward), re-adding it at its original index on undo (inverse). */ +export const deleteLayerActions = (layer: CanvasLayerContract, index: number): StructuralActions => ({ + forward: { ids: [layer.id], type: 'removeCanvasLayers' }, + inverse: { index, layer, type: 'addCanvasLayer' }, +}); + +/** Reorder to `nextIds` (forward), restoring `currentIds` on undo (inverse). */ +export const reorderLayerActions = (currentIds: readonly string[], nextIds: readonly string[]): StructuralActions => ({ + forward: { orderedIds: [...nextIds], type: 'reorderCanvasLayers' }, + inverse: { orderedIds: [...currentIds], type: 'reorderCanvasLayers' }, +}); + +/** A z-reorder direction for the layer hotkeys. */ +export type LayerReorderKind = 'forward' | 'backward' | 'front' | 'back'; + +/** The destination index for a z-reorder of the layer at `index` in a stack of `count`. */ +export const reorderTargetIndex = (index: number, count: number, kind: LayerReorderKind): number => { + switch (kind) { + case 'forward': + return Math.max(0, index - 1); + case 'backward': + return Math.min(count - 1, index + 1); + case 'front': + return 0; + case 'back': + return count - 1; + } +}; + +/** Moves `items[from]` to `to`, shifting the rest. Returns a new array. */ +const moveItem = (items: readonly T[], from: number, to: number): T[] => { + const next = [...items]; + const [moved] = next.splice(from, 1); + if (moved === undefined) { + return next; + } + next.splice(to, 0, moved); + return next; +}; + +/** + * The reordered id list for a z-reorder hotkey, or `null` when nothing would move + * (already at the boundary, or the index is out of range) so callers can no-op. + */ +export const reorderIdsForHotkey = ( + currentIds: readonly string[], + index: number, + kind: LayerReorderKind +): string[] | null => { + if (index < 0 || index >= currentIds.length) { + return null; + } + const target = reorderTargetIndex(index, currentIds.length, kind); + if (target === index) { + return null; + } + return moveItem(currentIds, index, target); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvasMigration.test.ts b/invokeai/frontend/webv2/src/workbench/canvasMigration.test.ts new file mode 100644 index 00000000000..74f455fbf7b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasMigration.test.ts @@ -0,0 +1,661 @@ +import { describe, expect, it } from 'vitest'; + +import { + createEmptyCanvasStateV2, + createNewCanvasStateV2, + DEFAULT_CANVAS_DOCUMENT_HEIGHT, + DEFAULT_CANVAS_DOCUMENT_WIDTH, + migrateCanvasStateToV2, + placementToTransform, +} from './canvasMigration'; +import { + createControlLayer, + createEmptyPaintLayer, + createInpaintMaskLayer, + createRegionalGuidanceLayer, + nextInpaintMaskName, +} from './widgets/layers/layerOps'; + +describe('placementToTransform', () => { + it('maps a placement rect to a scale-based transform relative to the source image size', () => { + expect(placementToTransform({ height: 200, width: 400, x: 10, y: 20 }, 200, 100)).toEqual({ + rotation: 0, + scaleX: 2, + scaleY: 2, + x: 10, + y: 20, + }); + }); + + it('falls back to a 1:1 scale when the source image has no dimensions', () => { + expect(placementToTransform({ height: 200, width: 400, x: 0, y: 0 }, 0, 0)).toEqual({ + rotation: 0, + scaleX: 1, + scaleY: 1, + x: 0, + y: 0, + }); + }); +}); + +describe('migrateCanvasStateToV2', () => { + it('round-trips z-image controls without rewriting persisted adapter kinds', () => { + const adapters = [ + { beginEndStepPct: [0, 0.75], controlMode: 'balanced', kind: 'controlnet', model: 'sd-control', weight: 0.75 }, + { beginEndStepPct: [0, 1], controlMode: null, kind: 't2i_adapter', model: 't2i', weight: 1 }, + { beginEndStepPct: [0, 1], controlMode: null, kind: 'control_lora', model: 'flux-control', weight: 0.75 }, + { beginEndStepPct: [0.2, 0.9], controlMode: null, kind: 'z_image_control', model: 'z-control', weight: 0.7 }, + ] as const; + const layers = adapters.map((adapter, index) => ({ + adapter, + blendMode: 'normal', + id: `control-${index}`, + isEnabled: true, + isLocked: false, + name: `Control ${index}`, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: true, + })); + const state = { + ...createEmptyCanvasStateV2(), + document: { ...createEmptyCanvasStateV2().document, layers }, + }; + + const migrated = migrateCanvasStateToV2(state); + + expect(migrated.document.layers.map((layer) => (layer.type === 'control' ? layer.adapter : null))).toEqual( + adapters + ); + }); + + it('normalizes an incomplete persisted Z-Image control with backend defaults', () => { + const state = createEmptyCanvasStateV2(); + const migrated = migrateCanvasStateToV2({ + ...state, + document: { + ...state.document, + layers: [ + { + adapter: { kind: 'z_image_control', model: 'z-control' }, + blendMode: 'normal', + id: 'z-control', + isEnabled: true, + isLocked: false, + name: 'Z Control', + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: true, + }, + ], + }, + }); + const layer = migrated.document.layers[0]; + + expect(layer?.type).toBe('control'); + expect(layer?.type === 'control' ? layer.adapter : null).toEqual({ + beginEndStepPct: [0, 1], + controlMode: null, + kind: 'z_image_control', + model: 'z-control', + weight: 0.75, + }); + }); + + it('normalizes control adapters in both the live document and saved snapshots', () => { + const state = createEmptyCanvasStateV2(); + const invalidLayer = { + adapter: { beginEndStepPct: [0.8, 0.2], controlMode: null, kind: 'z_image_control', model: null, weight: -1 }, + blendMode: 'normal', + id: 'z-control', + isEnabled: true, + isLocked: false, + name: 'Z Control', + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: true, + }; + const document = { ...state.document, layers: [invalidLayer] }; + const migrated = migrateCanvasStateToV2({ + ...state, + document, + snapshots: [{ createdAt: 'now', document, id: 'snapshot', name: 'Snapshot' }], + }); + + const getAdapter = (doc: (typeof migrated)['document']) => { + const layer = doc.layers[0]; + return layer?.type === 'control' ? layer.adapter : null; + }; + expect(getAdapter(migrated.document)).toMatchObject({ beginEndStepPct: [0, 1], weight: 0.75 }); + expect(getAdapter(migrated.snapshots[0]!.document)).toMatchObject({ beginEndStepPct: [0, 1], weight: 0.75 }); + }); + + it('keeps valid snapshots and discards malformed snapshot entries without blocking the live canvas', () => { + const state = createEmptyCanvasStateV2(); + const liveLayer = { + blendMode: 'normal', + id: 'live', + isEnabled: true, + isLocked: false, + name: 'Live', + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }; + const snapshotLayer = { + adapter: { kind: 'z_image_control', model: 'z-control' }, + ...liveLayer, + id: 'snapshot-control', + name: 'Snapshot Control', + type: 'control', + withTransparencyEffect: true, + }; + const validDocument = { ...state.document, layers: [snapshotLayer] }; + + const migrated = migrateCanvasStateToV2({ + ...state, + document: { ...state.document, layers: [liveLayer] }, + snapshots: [ + { createdAt: 'now', document: validDocument, id: 'valid', name: 'Valid' }, + null, + 'malformed', + {}, + { createdAt: 'now', id: 'missing-document', name: 'Missing document' }, + { createdAt: 'now', document: null, id: 'null-document', name: 'Null document' }, + { + createdAt: 'now', + document: { ...state.document, layers: { bad: true } }, + id: 'malformed-layers', + name: 'Malformed layers', + }, + ], + }); + + expect(migrated.document.layers.map((layer) => layer.id)).toEqual(['live']); + expect(migrated.snapshots).toHaveLength(1); + expect(migrated.snapshots[0]).toMatchObject({ createdAt: 'now', id: 'valid', name: 'Valid' }); + const validLayer = migrated.snapshots[0]!.document.layers[0]; + expect(validLayer?.type === 'control' ? validLayer.adapter : null).toEqual({ + beginEndStepPct: [0, 1], + controlMode: null, + kind: 'z_image_control', + model: 'z-control', + weight: 0.75, + }); + }); + + it.each([ + ['missing discriminant and base fields', { id: 'broken' }], + ['raster with invalid source', { ...createEmptyPaintLayer('Raster', 'raster'), source: null }], + ['control with invalid adapter', { ...createControlLayer('Control', 'control'), adapter: null }], + [ + 'regional guidance with invalid mask', + { ...createRegionalGuidanceLayer('Region', 0, 'region'), mask: { bitmap: null, fill: null } }, + ], + ['inpaint mask with invalid mask', { ...createInpaintMaskLayer('Mask', 'mask'), mask: null }], + ])('discards a snapshot containing a malformed layer: %s', (_label, malformedLayer) => { + const state = createEmptyCanvasStateV2(); + const migrated = migrateCanvasStateToV2({ + ...state, + snapshots: [ + { + createdAt: 'now', + document: { ...state.document, layers: [malformedLayer] }, + id: 'malformed', + name: 'Malformed', + }, + ], + }); + + expect(migrated.snapshots).toEqual([]); + expect(migrated.document.layers).toEqual([]); + }); + + it('round-trips valid snapshots containing every layer type', () => { + const state = createEmptyCanvasStateV2(); + const layers = [ + createEmptyPaintLayer('Raster', 'raster'), + createControlLayer('Control', 'control'), + createRegionalGuidanceLayer('Region', 0, 'region'), + createInpaintMaskLayer('Mask', 'mask'), + ]; + const snapshot = { + createdAt: 'now', + document: { ...state.document, layers, selectedLayerId: 'control' }, + id: 'valid-layers', + name: 'Valid layers', + }; + + const migrated = migrateCanvasStateToV2({ ...state, snapshots: [snapshot] }); + + expect(migrated.snapshots).toEqual([snapshot]); + }); + + it('filters malformed live layers without discarding valid live layers', () => { + const state = createEmptyCanvasStateV2(); + const valid = createEmptyPaintLayer('Valid', 'valid'); + const migrated = migrateCanvasStateToV2({ + ...state, + document: { ...state.document, layers: [{ id: 'broken' }, valid] }, + }); + + expect(migrated.document.layers).toEqual([valid]); + }); + + it('maps a v1 raster layer to a v2 raster layer positioned by transform', () => { + const v1Canvas = { + document: { + height: 768, + layers: [ + { + acceptedAt: '2026-06-09T00:00:00.000Z', + height: 512, + id: 'layer-1', + imageName: 'candidate.png', + imageUrl: '/api/v1/images/i/candidate.png/full', + label: 'Layer 1', + placement: { height: 300, opacity: 0.8, width: 600, x: 12, y: 24 }, + queuedAt: '2026-06-09T00:00:00.000Z', + sourceQueueItemId: 'queue-1', + thumbnailUrl: '/api/v1/images/i/candidate.png/thumbnail', + width: 1024, + }, + ], + version: 1, + width: 1024, + }, + stagingArea: { + areThumbnailsVisible: true, + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + version: 1, + }; + + const migrated = migrateCanvasStateToV2(v1Canvas); + + expect(migrated.version).toBe(2); + expect(migrated.document.layers).toHaveLength(1); + + const layer = migrated.document.layers[0]; + + expect(layer).toEqual({ + blendMode: 'normal', + id: 'layer-1', + isEnabled: true, + isLocked: false, + name: 'Layer 1', + opacity: 0.8, + source: { image: { height: 512, imageName: 'candidate.png', width: 1024 }, type: 'image' }, + transform: { rotation: 0, scaleX: 600 / 1024, scaleY: 300 / 512, x: 12, y: 24 }, + type: 'raster', + }); + }); + + it('generates an id and positional name for a layer missing them, and defaults a missing placement', () => { + const migrated = migrateCanvasStateToV2({ + document: { + height: 512, + layers: [{ height: 256, imageName: 'no-id.png', width: 256 }], + width: 512, + }, + version: 1, + }); + + const layer = migrated.document.layers[0]; + + expect(layer?.id).toBeTruthy(); + expect(layer?.name).toBe('Layer 1'); + expect(layer?.type).toBe('raster'); + + if (layer?.type === 'raster' && layer.source.type === 'image') { + expect(layer.source.image).toEqual({ height: 256, imageName: 'no-id.png', width: 256 }); + expect(layer.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }); + expect(layer.opacity).toBe(1); + } + }); + + it('migrates a bare imageName-string legacy layer', () => { + const migrated = migrateCanvasStateToV2({ + document: { height: 512, layers: ['legacy-image.png'], width: 512 }, + version: 1, + }); + + const layer = migrated.document.layers[0]; + + expect(layer?.name).toBe('legacy-image.png'); + + if (layer?.type === 'raster' && layer.source.type === 'image') { + expect(layer.source.image.imageName).toBe('legacy-image.png'); + } + }); + + it('supports the pre-`document` legacy shape with layers directly on the canvas', () => { + const migrated = migrateCanvasStateToV2({ + layers: ['ancient.png'], + version: 1, + }); + + expect(migrated.document.width).toBe(DEFAULT_CANVAS_DOCUMENT_WIDTH); + expect(migrated.document.height).toBe(DEFAULT_CANVAS_DOCUMENT_HEIGHT); + expect(migrated.document.layers).toHaveLength(1); + }); + + it('defaults bbox to the full document rect', () => { + const migrated = migrateCanvasStateToV2({ document: { height: 600, layers: [], width: 800 }, version: 1 }); + + expect(migrated.document.bbox).toEqual({ height: 600, width: 800, x: 0, y: 0 }); + }); + + it('defaults selectedLayerId to null (v1 had no selection concept)', () => { + const migrated = migrateCanvasStateToV2({ document: { height: 512, layers: [], width: 512 }, version: 1 }); + + expect(migrated.document.selectedLayerId).toBeNull(); + }); + + it('carries the staging area through unchanged and defaults autoSwitchMode to off', () => { + const pendingImages = [ + { + height: 512, + imageName: 'staged.png', + imageUrl: '/api/v1/images/i/staged.png/full', + placement: { height: 512, opacity: 1, width: 512, x: 0, y: 0 }, + queuedAt: '2026-06-09T00:00:00.000Z', + sourceQueueItemId: 'queue-1', + thumbnailUrl: '/api/v1/images/i/staged.png/thumbnail', + width: 512, + }, + ]; + + const migrated = migrateCanvasStateToV2({ + document: { height: 512, layers: [], width: 512 }, + stagingArea: { + areThumbnailsVisible: false, + isVisible: true, + pendingImageIds: ['staged.png'], + pendingImages, + selectedImageIndex: 0, + selectedLayerId: 'layer-x', + sourceQueueItemId: 'queue-1', + }, + version: 1, + }); + + expect(migrated.stagingArea).toEqual({ + areThumbnailsVisible: false, + autoSwitchMode: 'off', + isVisible: true, + pendingImageIds: ['staged.png'], + pendingImages, + selectedImageIndex: 0, + selectedLayerId: 'layer-x', + sourceQueueItemId: 'queue-1', + }); + // The candidate objects themselves are carried through by reference, not reshaped. + expect(migrated.stagingArea.pendingImages[0]).toBe(pendingImages[0]); + }); + + it('starts with no snapshots', () => { + const migrated = migrateCanvasStateToV2({ document: { height: 512, layers: [], width: 512 }, version: 1 }); + + expect(migrated.snapshots).toEqual([]); + }); + + it('passes already-v2 input through normalized rather than re-migrating it', () => { + const v2Canvas = { + document: { + background: 'transparent', + bbox: { height: 512, width: 512, x: 0, y: 0 }, + height: 512, + layers: [ + { + blendMode: 'multiply', + id: 'layer-1', + isEnabled: true, + isLocked: false, + name: 'Layer 1', + opacity: 0.5, + source: { image: { height: 100, imageName: 'v2.png', width: 100 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + selectedLayerId: 'layer-1', + version: 2, + width: 512, + }, + documentRevision: 0, + snapshots: [], + stagingArea: { + areThumbnailsVisible: true, + autoSwitchMode: 'latest', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + version: 2, + }; + + const migrated = migrateCanvasStateToV2(v2Canvas); + + expect(migrated).toEqual(v2Canvas); + // Not double-migrated: blendMode/opacity/selectedLayerId survive untouched, which a v1->v2 + // re-derivation from a (nonexistent) placement would not preserve. + expect(migrated.document.layers[0]).toEqual(v2Canvas.document.layers[0]); + expect(migrated.stagingArea.autoSwitchMode).toBe('latest'); + }); + + it('preserves the progress canvas staging auto-switch mode', () => { + const migrated = migrateCanvasStateToV2({ + document: { height: 512, layers: [], width: 512 }, + stagingArea: { autoSwitchMode: 'progress' }, + version: 2, + }); + + expect(migrated.stagingArea.autoSwitchMode).toBe('progress'); + }); + + it('normalizes the removed oldest canvas staging auto-switch mode to off', () => { + const migrated = migrateCanvasStateToV2({ + document: { height: 512, layers: [], width: 512 }, + stagingArea: { autoSwitchMode: 'oldest' }, + version: 2, + }); + + expect(migrated.stagingArea.autoSwitchMode).toBe('off'); + }); + + it('round-trips content-sized fields (paint offset, gradient width/height) on an already-v2 doc unchanged', () => { + const paintOffset = { x: -30, y: 45 }; + const gradientExtent = { height: 220, width: 180 }; + const v2Canvas = { + document: { + background: 'transparent', + bbox: { height: 512, width: 512, x: 0, y: 0 }, + height: 512, + layers: [ + { + blendMode: 'normal', + id: 'paint-1', + isEnabled: true, + isLocked: false, + name: 'Paint 1', + opacity: 1, + // Content-sized paint bitmap placed off-origin. + source: { bitmap: { height: 40, imageName: 'paint.png', width: 40 }, offset: paintOffset, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + { + blendMode: 'normal', + id: 'gradient-1', + isEnabled: true, + isLocked: false, + name: 'Gradient 1', + opacity: 1, + // Gradient carrying an explicit content extent (not document-sized). + source: { + angle: 45, + height: gradientExtent.height, + kind: 'linear', + stops: [ + { color: '#000000', offset: 0 }, + { color: '#ffffff', offset: 1 }, + ], + type: 'gradient', + width: gradientExtent.width, + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + selectedLayerId: 'paint-1', + version: 2, + width: 512, + }, + documentRevision: 3, + snapshots: [], + stagingArea: { + areThumbnailsVisible: true, + autoSwitchMode: 'off', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + version: 2, + }; + + const migrated = migrateCanvasStateToV2(v2Canvas); + + // Whole-state round-trip: normalization must not drop or rewrite the + // content-sizing fields the current schema carries. + expect(migrated).toEqual(v2Canvas); + const paintLayer = migrated.document.layers[0]; + const gradientLayer = migrated.document.layers[1]; + if (paintLayer?.type !== 'raster' || gradientLayer?.type !== 'raster') { + throw new Error('expected two raster layers to survive normalization'); + } + expect(paintLayer.source).toEqual({ + bitmap: { height: 40, imageName: 'paint.png', width: 40 }, + offset: paintOffset, + type: 'paint', + }); + expect(gradientLayer.source).toMatchObject({ + height: gradientExtent.height, + type: 'gradient', + width: gradientExtent.width, + }); + }); + + it.each([undefined, null, 'garbage', 42, [], {}])( + 'falls back to a fresh empty v2 state for garbage input (%j)', + (garbage) => { + const migrated = migrateCanvasStateToV2(garbage); + + expect(migrated).toEqual(createEmptyCanvasStateV2()); + expect(migrated.document.width).toBe(DEFAULT_CANVAS_DOCUMENT_WIDTH); + expect(migrated.document.height).toBe(DEFAULT_CANVAS_DOCUMENT_HEIGHT); + expect(migrated.document.layers).toEqual([]); + expect(migrated.stagingArea.autoSwitchMode).toBe('off'); + } + ); +}); + +describe('createEmptyCanvasStateV2', () => { + it('creates a well-formed empty v2 canvas at the default document size', () => { + const state = createEmptyCanvasStateV2(); + + expect(state).toEqual({ + document: { + background: 'transparent', + bbox: { height: DEFAULT_CANVAS_DOCUMENT_HEIGHT, width: DEFAULT_CANVAS_DOCUMENT_WIDTH, x: 0, y: 0 }, + height: DEFAULT_CANVAS_DOCUMENT_HEIGHT, + layers: [], + selectedLayerId: null, + version: 2, + width: DEFAULT_CANVAS_DOCUMENT_WIDTH, + }, + documentRevision: 0, + snapshots: [], + stagingArea: { + areThumbnailsVisible: true, + autoSwitchMode: 'off', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + version: 2, + }); + }); + + it('honors a custom document size', () => { + const state = createEmptyCanvasStateV2(800, 600); + + expect(state.document.width).toBe(800); + expect(state.document.height).toBe(600); + expect(state.document.bbox).toEqual({ height: 600, width: 800, x: 0, y: 0 }); + }); +}); + +describe('createNewCanvasStateV2', () => { + it('seeds exactly one empty inpaint mask, selected, with legacy-default fill', () => { + const state = createNewCanvasStateV2(); + const { layers, selectedLayerId } = state.document; + + expect(layers).toHaveLength(1); + const mask = layers[0]; + expect(mask?.type).toBe('inpaint_mask'); + expect(mask?.name).toBe('Inpaint Mask 1'); + expect(mask?.isEnabled).toBe(true); + expect(mask?.isLocked).toBe(false); + expect(mask?.opacity).toBe(1); + // Empty = no bitmap (no strokes); the diagonal-hatch fill is the legacy default. + expect(mask && 'mask' in mask ? mask.mask : null).toEqual({ + bitmap: null, + fill: { color: '#e07575', style: 'diagonal' }, + }); + // The seeded mask is the initially selected layer. + expect(selectedLayerId).toBe(mask?.id); + }); + + it('matches the layers-panel inpaint-mask factory shape (kept in lockstep)', () => { + const state = createNewCanvasStateV2(); + const mask = state.document.layers[0]; + // Rebuild the expected layer via the canonical factory, pinning the minted id + // and name so only the contract shape is compared. + const expected = createInpaintMaskLayer(nextInpaintMaskName([]), mask?.id ?? ''); + + expect(mask).toEqual(expected); + }); + + it('honors a custom document size while staying otherwise identical to an empty canvas', () => { + const state = createNewCanvasStateV2(800, 600); + + expect(state.document.width).toBe(800); + expect(state.document.height).toBe(600); + expect(state.document.bbox).toEqual({ height: 600, width: 800, x: 0, y: 0 }); + // Only the document's layers/selection differ from the empty canvas. + expect({ ...state, document: { ...state.document, layers: [], selectedLayerId: null } }).toEqual( + createEmptyCanvasStateV2(800, 600) + ); + }); + + it('does not disturb the migration/empty path (garbage still migrates to an empty, mask-free canvas)', () => { + // The new-canvas seed is scoped to fresh projects only; migrating unknown + // input keeps producing an empty, mask-free document. + expect(migrateCanvasStateToV2('garbage').document.layers).toEqual([]); + expect(createEmptyCanvasStateV2().document.layers).toEqual([]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvasMigration.ts b/invokeai/frontend/webv2/src/workbench/canvasMigration.ts new file mode 100644 index 00000000000..a55332cf374 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasMigration.ts @@ -0,0 +1,534 @@ +import { z } from 'zod'; + +/** + * Canvas v1 -> v2 document migration. + * + * `CanvasStateContract` (v1) only ever produced a flat stack of single-image + * raster layers positioned by an absolute `placement` rect. `CanvasStateContractV2` + * (see `types.ts`) generalizes the document into a typed layer union (raster, + * control, regional guidance, inpaint mask) positioned by a `transform`, with + * bitmaps referenced by `imageName` rather than by resolved URL. + * + * `migrateCanvasStateToV2` accepts genuinely unknown input (as read from + * localStorage) so it doubles as both the v1->v2 converter and the + * garbage/undefined-input fallback used when normalizing a loaded project. + * Already-v2 input passes through normalized, not double-migrated. + */ +import type { + CanvasDocumentContractV2, + CanvasImageRef, + CanvasInpaintMaskLayerContract, + CanvasLayerBaseContract, + CanvasRasterLayerContractV2, + CanvasStagingAreaContractV2, + CanvasStateContractV2, +} from './types'; + +import { normalizeControlAdapter } from './controlAdapters'; + +export const DEFAULT_CANVAS_DOCUMENT_WIDTH = 1024; +export const DEFAULT_CANVAS_DOCUMENT_HEIGHT = 1024; + +const createMigrationId = (prefix: string): string => + `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + +const zFiniteNumber = z.number().finite(); +const zCoordinate = z.object({ x: zFiniteNumber, y: zFiniteNumber }); +const zImageRef = z.object({ + contentHash: z.string().optional(), + height: zFiniteNumber.nonnegative(), + imageName: z.string(), + width: zFiniteNumber.nonnegative(), +}); +const zPaintSource = z.object({ + bitmap: zImageRef.nullable(), + offset: zCoordinate.optional(), + type: z.literal('paint'), +}); +const zLayerSource = z.discriminatedUnion('type', [ + zPaintSource, + z.object({ image: zImageRef, type: z.literal('image') }), + z.object({ + align: z.enum(['left', 'center', 'right']), + color: z.string(), + content: z.string(), + fontFamily: z.string(), + fontSize: zFiniteNumber, + fontWeight: zFiniteNumber, + lineHeight: zFiniteNumber, + type: z.literal('text'), + }), + z.object({ + fill: z.string().nullable(), + height: zFiniteNumber, + kind: z.enum(['rect', 'ellipse', 'polygon']), + points: z.array(zCoordinate).optional(), + stroke: z.string().nullable(), + strokeWidth: zFiniteNumber, + type: z.literal('shape'), + width: zFiniteNumber, + }), + z.object({ + angle: zFiniteNumber, + height: zFiniteNumber.positive().optional(), + kind: z.enum(['linear', 'radial']), + stops: z.array(z.object({ color: z.string(), offset: zFiniteNumber })), + type: z.literal('gradient'), + width: zFiniteNumber.positive().optional(), + }), +]); +const zTransform = z.object({ + rotation: zFiniteNumber, + scaleX: zFiniteNumber, + scaleY: zFiniteNumber, + x: zFiniteNumber, + y: zFiniteNumber, +}); +const zFilter = z.object({ settings: z.record(z.string(), z.unknown()), type: z.string() }); +const zCurve = z.array(z.tuple([zFiniteNumber, zFiniteNumber])); +const zAdjustments = z.object({ + brightness: zFiniteNumber, + contrast: zFiniteNumber, + curves: z.object({ b: zCurve, g: zCurve, r: zCurve }).optional(), + saturation: zFiniteNumber, +}); +const zControlAdapter = z + .object({ + beginEndStepPct: z.tuple([zFiniteNumber, zFiniteNumber]), + controlMode: z.enum(['balanced', 'more_prompt', 'more_control', 'unbalanced']).nullable(), + kind: z.enum(['controlnet', 't2i_adapter', 'control_lora', 'z_image_control']), + model: z.string().nullable(), + weight: zFiniteNumber, + }) + .refine(({ beginEndStepPct }) => { + const [begin, end] = beginEndStepPct; + return begin >= 0 && end <= 1 && begin < end; + }) + .refine(({ kind, weight }) => weight >= (kind === 'z_image_control' ? 0 : -1) && weight <= 2); +const zMask = z.object({ + bitmap: zImageRef.nullable(), + fill: z.object({ + color: z.string(), + style: z.enum(['solid', 'grid', 'crosshatch', 'diagonal', 'horizontal', 'vertical']), + }), + offset: zCoordinate.optional(), +}); +const zGeneratedImage = z.object({ + height: zFiniteNumber.nonnegative(), + imageName: z.string(), + imageUrl: z.string(), + queuedAt: z.string(), + sourceQueueItemId: z.string(), + thumbnailUrl: z.string(), + width: zFiniteNumber.nonnegative(), +}); +const zModelIdentifier = z.object({ base: z.string(), key: z.string(), name: z.string(), type: z.string() }); +const zReferenceImage = z.object({ + config: z.discriminatedUnion('type', [ + z.object({ + beginEndStepPct: z.tuple([zFiniteNumber, zFiniteNumber]), + clipVisionModel: z.enum(['ViT-H', 'ViT-G', 'ViT-L']), + image: zGeneratedImage.nullable(), + method: z.enum(['full', 'style', 'composition', 'style_strong', 'style_precise']), + model: zModelIdentifier.nullable(), + type: z.literal('ip_adapter'), + weight: zFiniteNumber, + }), + z.object({ + image: zGeneratedImage.nullable(), + imageInfluence: z.enum(['lowest', 'low', 'medium', 'high', 'highest']), + model: zModelIdentifier.nullable(), + type: z.literal('flux_redux'), + }), + z.object({ + image: zGeneratedImage.nullable(), + model: zModelIdentifier.nullable(), + type: z.literal('flux_kontext_reference_image'), + }), + z.object({ image: zGeneratedImage.nullable(), type: z.literal('flux2_reference_image') }), + z.object({ image: zGeneratedImage.nullable(), type: z.literal('qwen_image_reference_image') }), + z.object({ image: zGeneratedImage.nullable(), type: z.literal('external_reference_image') }), + ]), + id: z.string(), + isEnabled: z.boolean(), +}); +const zLayerBase = z.object({ + blendMode: z.enum([ + 'normal', + 'multiply', + 'screen', + 'overlay', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'exclusion', + 'hue', + 'saturation', + 'color', + 'luminosity', + ]), + id: z.string(), + isEnabled: z.boolean(), + isLocked: z.boolean(), + name: z.string(), + opacity: zFiniteNumber.min(0).max(1), + transform: zTransform, +}); +const zCanvasLayer = z.discriminatedUnion('type', [ + zLayerBase.extend({ + adjustments: zAdjustments.optional(), + filter: zFilter.optional(), + isTransparencyLocked: z.boolean().optional(), + source: zLayerSource, + type: z.literal('raster'), + }), + zLayerBase.extend({ + adapter: zControlAdapter, + filter: zFilter.optional(), + source: zLayerSource, + type: z.literal('control'), + withTransparencyEffect: z.boolean(), + }), + zLayerBase.extend({ + autoNegative: z.boolean(), + mask: zMask, + negativePrompt: z.string().nullable(), + positivePrompt: z.string().nullable(), + referenceImages: z.array(zReferenceImage), + type: z.literal('regional_guidance'), + }), + zLayerBase.extend({ + denoiseLimit: zFiniteNumber.optional(), + mask: zMask, + noiseLevel: zFiniteNumber.optional(), + type: z.literal('inpaint_mask'), + }), +]); + +const asNumber = (value: unknown, fallback: number): number => (typeof value === 'number' ? value : fallback); + +const asString = (value: unknown, fallback: string): string => (typeof value === 'string' ? value : fallback); + +const asPositiveNumber = (value: unknown, fallback: number): number => { + const numeric = asNumber(value, fallback); + + return numeric > 0 ? numeric : fallback; +}; + +export const createEmptyCanvasStateV2 = ( + width = DEFAULT_CANVAS_DOCUMENT_WIDTH, + height = DEFAULT_CANVAS_DOCUMENT_HEIGHT +): CanvasStateContractV2 => ({ + document: createEmptyCanvasDocumentV2(width, height), + documentRevision: 0, + snapshots: [], + stagingArea: createDefaultStagingAreaV2(), + version: 2, +}); + +export const createEmptyCanvasDocumentV2 = ( + width = DEFAULT_CANVAS_DOCUMENT_WIDTH, + height = DEFAULT_CANVAS_DOCUMENT_HEIGHT +): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height, width, x: 0, y: 0 }, + height, + layers: [], + selectedLayerId: null, + version: 2, + width, +}); + +/** + * A brand-new project's default inpaint mask: one empty mask (no bitmap/strokes) + * with the legacy-default diagonal-hatch fill in the first cycled mask colour + * (legacy `rgb(224,117,117)`). Mirrors `createInpaintMaskLayer` / + * `DEFAULT_INPAINT_MASK_FILL` in `widgets/layers/layerOps` — duplicated here so + * the pure reducer/migration module doesn't pull in the layers-panel/engine + * module graph; `canvasMigration.test.ts` locks the shape against that factory. + */ +const createInitialInpaintMaskLayer = (): CanvasInpaintMaskLayerContract => ({ + blendMode: 'normal', + id: createMigrationId('layer'), + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + name: 'Inpaint Mask 1', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +/** + * A fresh canvas state for a newly created project: an empty document that + * already carries one empty inpaint mask (selected), matching legacy, which seeds + * a canvas session with an inpaint mask present. The mask has no content, so it + * does NOT flip generation-mode detection to inpaint (see `detectCanvasMode`). + * + * This is the NEW-canvas path only. The migration / garbage-fallback path + * (`migrateCanvasStateToV2`) and `createEmptyCanvasStateV2` stay empty, so + * existing and migrated documents are left untouched. + */ +export const createNewCanvasStateV2 = ( + width = DEFAULT_CANVAS_DOCUMENT_WIDTH, + height = DEFAULT_CANVAS_DOCUMENT_HEIGHT +): CanvasStateContractV2 => { + const base = createEmptyCanvasStateV2(width, height); + const mask = createInitialInpaintMaskLayer(); + + return { + ...base, + document: { ...base.document, layers: [mask], selectedLayerId: mask.id }, + }; +}; + +const createDefaultStagingAreaV2 = (): CanvasStagingAreaContractV2 => ({ + areThumbnailsVisible: true, + autoSwitchMode: 'off', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, +}); + +/** + * Converts a v1 `{x,y,width,height}` placement rect, plus the native size of the image it + * places, into a v2 `transform`. Shared by the migration path and the live "accept staged + * image into a raster layer" reducer, which still works from a v1-shaped placement. + */ +export const placementToTransform = ( + placement: { x: number; y: number; width: number; height: number }, + imageWidth: number, + imageHeight: number +): CanvasLayerBaseContract['transform'] => ({ + rotation: 0, + scaleX: imageWidth > 0 ? placement.width / imageWidth : 1, + scaleY: imageHeight > 0 ? placement.height / imageHeight : 1, + x: placement.x, + y: placement.y, +}); + +/** Migrates a single v1 `CanvasRasterLayerContract` (or bare imageName string) into a v2 raster layer. */ +const migrateLayerToV2 = (rawLayer: unknown, index: number): CanvasRasterLayerContractV2 => { + if (typeof rawLayer === 'string') { + return { + blendMode: 'normal', + id: rawLayer || createMigrationId('layer'), + isEnabled: true, + isLocked: false, + name: rawLayer || `Layer ${index + 1}`, + opacity: 1, + source: { image: { height: 0, imageName: rawLayer, width: 0 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }; + } + + const layer = isRecord(rawLayer) ? rawLayer : {}; + const image: CanvasImageRef = { + height: asNumber(layer.height, 0), + imageName: asString(layer.imageName, ''), + width: asNumber(layer.width, 0), + }; + const placement = isRecord(layer.placement) ? layer.placement : {}; + const placementX = asNumber(placement.x, 0); + const placementY = asNumber(placement.y, 0); + const placementWidth = asNumber(placement.width, image.width); + const placementHeight = asNumber(placement.height, image.height); + const placementOpacity = typeof placement.opacity === 'number' ? placement.opacity : 1; + + return { + blendMode: 'normal', + id: asString(layer.id, createMigrationId('layer')), + isEnabled: true, + isLocked: false, + name: asString(layer.label, `Layer ${index + 1}`), + opacity: placementOpacity, + source: { image, type: 'image' }, + transform: placementToTransform( + { height: placementHeight, width: placementWidth, x: placementX, y: placementY }, + image.width, + image.height + ), + type: 'raster', + }; +}; + +const migrateDocumentToV2 = (rawCanvas: Record): CanvasDocumentContractV2 => { + const rawDocument = isRecord(rawCanvas.document) ? rawCanvas.document : rawCanvas; + const width = asPositiveNumber(rawDocument.width, DEFAULT_CANVAS_DOCUMENT_WIDTH); + const height = asPositiveNumber(rawDocument.height, DEFAULT_CANVAS_DOCUMENT_HEIGHT); + const rawLayers = Array.isArray(rawDocument.layers) ? rawDocument.layers : []; + + return { + background: 'transparent', + bbox: { height, width, x: 0, y: 0 }, + height, + layers: rawLayers.map((rawLayer, index) => migrateLayerToV2(rawLayer, index)), + selectedLayerId: null, + version: 2, + width, + }; +}; + +const AUTO_SWITCH_MODES: CanvasStagingAreaContractV2['autoSwitchMode'][] = ['off', 'latest', 'progress']; + +const asAutoSwitchMode = (value: unknown): CanvasStagingAreaContractV2['autoSwitchMode'] => + AUTO_SWITCH_MODES.includes(value as CanvasStagingAreaContractV2['autoSwitchMode']) + ? (value as CanvasStagingAreaContractV2['autoSwitchMode']) + : 'off'; + +/** + * Normalizes a v1 or v2 staging area. v1 never had `autoSwitchMode`, so it's absent from raw + * input and defaults to `'off'`; already-v2 input keeps its existing value. + */ +const migrateStagingAreaToV2 = (rawCanvas: Record): CanvasStagingAreaContractV2 => { + const rawStagingArea = isRecord(rawCanvas.stagingArea) ? rawCanvas.stagingArea : {}; + const defaults = createDefaultStagingAreaV2(); + + return { + areThumbnailsVisible: + typeof rawStagingArea.areThumbnailsVisible === 'boolean' + ? rawStagingArea.areThumbnailsVisible + : defaults.areThumbnailsVisible, + autoSwitchMode: asAutoSwitchMode(rawStagingArea.autoSwitchMode), + isVisible: typeof rawStagingArea.isVisible === 'boolean' ? rawStagingArea.isVisible : defaults.isVisible, + pendingImageIds: Array.isArray(rawStagingArea.pendingImageIds) + ? (rawStagingArea.pendingImageIds as CanvasStagingAreaContractV2['pendingImageIds']) + : defaults.pendingImageIds, + pendingImages: Array.isArray(rawStagingArea.pendingImages) + ? (rawStagingArea.pendingImages as CanvasStagingAreaContractV2['pendingImages']) + : defaults.pendingImages, + selectedImageIndex: asNumber(rawStagingArea.selectedImageIndex, defaults.selectedImageIndex), + ...(typeof rawStagingArea.selectedLayerId === 'string' ? { selectedLayerId: rawStagingArea.selectedLayerId } : {}), + ...(typeof rawStagingArea.sourceQueueItemId === 'string' + ? { sourceQueueItemId: rawStagingArea.sourceQueueItemId } + : {}), + }; +}; + +const isCanvasStateV2 = (canvas: unknown): canvas is Record & { version: 2 } => + isRecord(canvas) && canvas.version === 2; + +const normalizeCanvasLayer = (value: unknown): CanvasDocumentContractV2['layers'][number] | null => { + if (!isRecord(value)) { + return null; + } + let candidate: Record = value; + if (value.type === 'control') { + candidate = { + ...value, + adapter: normalizeControlAdapter(value.adapter), + withTransparencyEffect: value.withTransparencyEffect === undefined ? true : value.withTransparencyEffect, + }; + } else if (value.type === 'regional_guidance') { + candidate = { + ...value, + autoNegative: value.autoNegative === undefined ? false : value.autoNegative, + negativePrompt: value.negativePrompt === undefined ? null : value.negativePrompt, + positivePrompt: value.positivePrompt === undefined ? null : value.positivePrompt, + referenceImages: value.referenceImages === undefined ? [] : value.referenceImages, + }; + } + const parsed = zCanvasLayer.safeParse(candidate); + return parsed.success ? (candidate as unknown as CanvasDocumentContractV2['layers'][number]) : null; +}; + +export const normalizeCanvasDocumentControlAdapters = ( + document: CanvasDocumentContractV2 +): CanvasDocumentContractV2 => ({ + ...document, + layers: document.layers.flatMap((layer) => { + const normalized = normalizeCanvasLayer(layer); + return normalized ? [normalized] : []; + }), +}); + +const normalizeCanvasDocumentV2 = (value: unknown, requireValidLayers: boolean): CanvasDocumentContractV2 | null => { + const rawDocument = isRecord(value) ? value : {}; + const rawLayers = Array.isArray(rawDocument.layers) ? rawDocument.layers : []; + const layers = rawLayers.flatMap((layer) => { + const normalized = normalizeCanvasLayer(layer); + return normalized ? [normalized] : []; + }); + if (requireValidLayers && (!Array.isArray(rawDocument.layers) || layers.length !== rawLayers.length)) { + return null; + } + const width = asPositiveNumber(rawDocument.width, DEFAULT_CANVAS_DOCUMENT_WIDTH); + const height = asPositiveNumber(rawDocument.height, DEFAULT_CANVAS_DOCUMENT_HEIGHT); + const bbox = isRecord(rawDocument.bbox) ? rawDocument.bbox : {}; + return { + background: + rawDocument.background === 'transparent' || isRecord(rawDocument.background) + ? (rawDocument.background as CanvasDocumentContractV2['background']) + : 'transparent', + bbox: { + height: asPositiveNumber(bbox.height, height), + width: asPositiveNumber(bbox.width, width), + x: asNumber(bbox.x, 0), + y: asNumber(bbox.y, 0), + }, + height, + layers, + selectedLayerId: typeof rawDocument.selectedLayerId === 'string' ? rawDocument.selectedLayerId : null, + version: 2, + width, + }; +}; + +const normalizeCanvasSnapshot = (value: unknown): CanvasStateContractV2['snapshots'][number] | null => { + if ( + !isRecord(value) || + typeof value.id !== 'string' || + typeof value.name !== 'string' || + typeof value.createdAt !== 'string' + ) { + return null; + } + const document = normalizeCanvasDocumentV2(value.document, true); + return document ? ({ ...value, document } as CanvasStateContractV2['snapshots'][number]) : null; +}; + +/** Defensively re-normalizes an already-v2 canvas state (fills in anything missing without re-deriving layers). */ +const normalizeCanvasStateV2 = (canvas: Record): CanvasStateContractV2 => { + const document = normalizeCanvasDocumentV2(canvas.document, false) ?? createEmptyCanvasDocumentV2(); + + return { + document, + documentRevision: asNumber(canvas.documentRevision, 0), + snapshots: Array.isArray(canvas.snapshots) + ? canvas.snapshots.flatMap((snapshot) => { + const normalized = normalizeCanvasSnapshot(snapshot); + return normalized ? [normalized] : []; + }) + : [], + stagingArea: migrateStagingAreaToV2(canvas), + version: 2, + }; +}; + +/** + * Converts unknown persisted canvas state (v1, v2, or garbage) into `CanvasStateContractV2`. + * Already-v2 input is normalized (defaults filled in) but not re-derived from placements. + */ +export const migrateCanvasStateToV2 = (canvas: unknown): CanvasStateContractV2 => { + if (isCanvasStateV2(canvas)) { + return normalizeCanvasStateV2(canvas); + } + + const rawCanvas = isRecord(canvas) ? canvas : {}; + + return { + document: migrateDocumentToV2(rawCanvas), + documentRevision: 0, + snapshots: [], + stagingArea: migrateStagingAreaToV2(rawCanvas), + version: 2, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvasProjectMutationPort.test.ts b/invokeai/frontend/webv2/src/workbench/canvasProjectMutationPort.test.ts new file mode 100644 index 00000000000..dfb4887392b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasProjectMutationPort.test.ts @@ -0,0 +1,255 @@ +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { + CanvasInpaintMaskLayerContract, + CanvasLayerContract, + CanvasLayerSourceContract, + CanvasRasterLayerContractV2, +} from '@workbench/types'; + +import { createBitmapStore } from '@workbench/canvas-engine/document/bitmapStore'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it, vi } from 'vitest'; + +import { createEmptyCanvasDocumentV2 } from './canvasMigration'; +import { createCanvasProjectMutationPort } from './canvasProjectMutationPort'; +import { createWorkbenchStore } from './workbenchStore'; + +const createDeferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +const drainUntil = async (predicate: () => boolean, maxTicks = 50): Promise => { + for (let tick = 0; tick < maxTicks && !predicate(); tick += 1) { + await Promise.resolve(); + } +}; + +const layerBase = (id: string) => ({ + blendMode: 'normal' as const, + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, +}); + +const createPaintLayer = (id: string): CanvasRasterLayerContractV2 => ({ + ...layerBase(id), + source: { bitmap: null, type: 'paint' }, + type: 'raster', +}); + +const createMaskLayer = (id: string): CanvasInpaintMaskLayerContract => ({ + ...layerBase(id), + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + type: 'inpaint_mask', +}); + +const getLayer = ( + store: ReturnType, + projectId: string, + layerId: string +): CanvasLayerContract | undefined => + store + .getState() + .projects.find((project) => project.id === projectId) + ?.canvas.document.layers.find((layer) => layer.id === layerId); + +const getPaintSource = ( + layer: CanvasLayerContract | undefined +): Extract | null => { + if (!layer) { + return null; + } + if (layer.type === 'raster' || layer.type === 'control') { + return layer.source.type === 'paint' ? layer.source : null; + } + return { bitmap: layer.mask.bitmap, offset: layer.mask.offset, type: 'paint' }; +}; + +const setupProjects = (originLayer: CanvasLayerContract, otherLayer: CanvasLayerContract) => { + const store = createWorkbenchStore(); + const originProjectId = store.getState().activeProjectId; + store.dispatch({ + mutation: { document: createEmptyCanvasDocumentV2(), type: 'replaceCanvasDocument' }, + projectId: originProjectId, + type: 'applyCanvasProjectMutation', + }); + store.dispatch({ + mutation: { layer: originLayer, type: 'addCanvasLayer' }, + projectId: originProjectId, + type: 'applyCanvasProjectMutation', + }); + store.dispatch({ type: 'createProject' }); + const otherProjectId = store.getState().activeProjectId; + store.dispatch({ + mutation: { document: createEmptyCanvasDocumentV2(), type: 'replaceCanvasDocument' }, + projectId: otherProjectId, + type: 'applyCanvasProjectMutation', + }); + store.dispatch({ + mutation: { layer: otherLayer, type: 'addCanvasLayer' }, + projectId: otherProjectId, + type: 'applyCanvasProjectMutation', + }); + store.dispatch({ projectId: originProjectId, type: 'switchProject' }); + return { originProjectId, otherProjectId, store }; +}; + +const beginUpload = ( + store: ReturnType, + projectId: string, + layerId: string, + isMask = false, + sourceReaders?: { + authoritative?: (id: string) => CanvasLayerSourceContract | null; + mirrored?: (id: string) => CanvasLayerSourceContract | null; + } +) => { + const uploaded = createDeferred<{ height: number; imageName: string; width: number }>(); + const uploadImage = vi.fn(() => uploaded.promise); + const surface: RasterSurface = createTestStubRasterBackend().createSurface(8, 8); + const port = createCanvasProjectMutationPort(store, projectId); + const onError = vi.fn(); + const bitmapStore = createBitmapStore({ + debounceMs: 60_000, + dispatch: port.dispatch, + dispatchBitmap: isMask + ? (id, bitmap, offset) => + port.dispatch({ + config: { layerType: 'inpaint_mask', mask: { bitmap, offset } }, + id, + type: 'updateCanvasLayerConfig', + }) + : undefined, + encodeSurface: () => Promise.resolve(new Blob(['pixels'], { type: 'image/png' })), + getAuthoritativeLayerSource: sourceReaders?.authoritative, + getLayerSource: sourceReaders?.mirrored ?? ((id) => getPaintSource(getLayer(store, projectId, id))), + getLayerSurface: () => ({ offset: { x: 3, y: 4 }, surface }), + hashBlob: () => Promise.resolve('pixels-hash'), + onError, + uploadImage, + }); + bitmapStore.markLayerDirty(layerId); + const flush = bitmapStore.flushPendingUploads(); + return { bitmapStore, flush, onError, uploadImage, uploaded }; +}; + +describe('project-bound bitmap persistence', () => { + it('finishes an upload in its origin project after switching to a project with a different layer id', async () => { + const h = setupProjects(createPaintLayer('origin-layer'), createPaintLayer('other-layer')); + const upload = beginUpload(h.store, h.originProjectId, 'origin-layer'); + await drainUntil(() => upload.uploadImage.mock.calls.length === 1); + + h.store.dispatch({ projectId: h.otherProjectId, type: 'switchProject' }); + upload.uploaded.resolve({ height: 8, imageName: 'origin-upload.png', width: 8 }); + await upload.flush; + + expect(getPaintSource(getLayer(h.store, h.originProjectId, 'origin-layer'))?.bitmap?.imageName).toBe( + 'origin-upload.png' + ); + expect(getPaintSource(getLayer(h.store, h.otherProjectId, 'other-layer'))?.bitmap).toBeNull(); + upload.bitmapStore.dispose(); + }); + + it('does not mutate a colliding layer id in the newly active project', async () => { + const h = setupProjects(createPaintLayer('shared-layer'), createPaintLayer('shared-layer')); + const upload = beginUpload(h.store, h.originProjectId, 'shared-layer'); + await drainUntil(() => upload.uploadImage.mock.calls.length === 1); + + h.store.dispatch({ projectId: h.otherProjectId, type: 'switchProject' }); + upload.uploaded.resolve({ height: 8, imageName: 'origin-shared.png', width: 8 }); + await upload.flush; + + expect(getPaintSource(getLayer(h.store, h.originProjectId, 'shared-layer'))?.bitmap?.imageName).toBe( + 'origin-shared.png' + ); + expect(getPaintSource(getLayer(h.store, h.otherProjectId, 'shared-layer'))?.bitmap).toBeNull(); + upload.bitmapStore.dispose(); + }); + + it('routes a mask upload back to the origin project after switching projects', async () => { + const h = setupProjects(createMaskLayer('shared-mask'), createMaskLayer('shared-mask')); + const upload = beginUpload(h.store, h.originProjectId, 'shared-mask', true); + await drainUntil(() => upload.uploadImage.mock.calls.length === 1); + + h.store.dispatch({ projectId: h.otherProjectId, type: 'switchProject' }); + upload.uploaded.resolve({ height: 8, imageName: 'origin-mask.png', width: 8 }); + await upload.flush; + + expect(getPaintSource(getLayer(h.store, h.originProjectId, 'shared-mask'))?.bitmap?.imageName).toBe( + 'origin-mask.png' + ); + expect(getPaintSource(getLayer(h.store, h.otherProjectId, 'shared-mask'))?.bitmap).toBeNull(); + upload.bitmapStore.dispose(); + }); + + it('accepts the pending upload after switching away from and reacquiring the origin project', async () => { + const h = setupProjects(createPaintLayer('reacquired'), createPaintLayer('other')); + const upload = beginUpload(h.store, h.originProjectId, 'reacquired'); + await drainUntil(() => upload.uploadImage.mock.calls.length === 1); + + h.store.dispatch({ projectId: h.otherProjectId, type: 'switchProject' }); + h.store.dispatch({ projectId: h.originProjectId, type: 'switchProject' }); + upload.uploaded.resolve({ height: 8, imageName: 'reacquired.png', width: 8 }); + await upload.flush; + + expect(getPaintSource(getLayer(h.store, h.originProjectId, 'reacquired'))?.bitmap?.imageName).toBe( + 'reacquired.png' + ); + upload.bitmapStore.dispose(); + }); + + it('treats a project-bound bitmap commit as accepted when a subscriber throws after the reducer lands it', async () => { + const h = setupProjects(createPaintLayer('subscriber-throw'), createPaintLayer('other')); + let mirroredSource = getPaintSource(getLayer(h.store, h.originProjectId, 'subscriber-throw')); + const unsubscribe = h.store.subscribe(() => { + throw new Error('subscriber failed after commit'); + }); + const unsubscribeMirror = h.store.subscribe(() => { + mirroredSource = getPaintSource(getLayer(h.store, h.originProjectId, 'subscriber-throw')); + }); + const upload = beginUpload(h.store, h.originProjectId, 'subscriber-throw', false, { + authoritative: (id) => getPaintSource(getLayer(h.store, h.originProjectId, id)), + mirrored: () => mirroredSource, + }); + await drainUntil(() => upload.uploadImage.mock.calls.length === 1); + + upload.uploaded.resolve({ height: 8, imageName: 'landed-before-subscriber-throw.png', width: 8 }); + await expect(upload.flush).resolves.toBeUndefined(); + await expect(upload.bitmapStore.flushPendingUploads()).resolves.toBeUndefined(); + + expect(getPaintSource(getLayer(h.store, h.originProjectId, 'subscriber-throw'))).toMatchObject({ + bitmap: { imageName: 'landed-before-subscriber-throw.png' }, + offset: { x: 3, y: 4 }, + }); + expect(upload.uploadImage).toHaveBeenCalledOnce(); + expect(upload.onError).not.toHaveBeenCalled(); + expect(mirroredSource?.bitmap).toBeNull(); + unsubscribeMirror(); + unsubscribe(); + upload.bitmapStore.dispose(); + }); + + it('treats project deletion during upload as terminal removal', async () => { + const h = setupProjects(createPaintLayer('deleted-project-layer'), createPaintLayer('survivor')); + const upload = beginUpload(h.store, h.originProjectId, 'deleted-project-layer'); + await drainUntil(() => upload.uploadImage.mock.calls.length === 1); + + h.store.dispatch({ projectId: h.otherProjectId, type: 'switchProject' }); + h.store.dispatch({ projectId: h.originProjectId, type: 'closeProject' }); + upload.uploaded.resolve({ height: 8, imageName: 'orphaned.png', width: 8 }); + await upload.flush; + await upload.bitmapStore.flushPendingUploads(); + + expect(h.store.getState().projects.some((project) => project.id === h.originProjectId)).toBe(false); + expect(getPaintSource(getLayer(h.store, h.otherProjectId, 'survivor'))?.bitmap).toBeNull(); + expect(upload.uploadImage).toHaveBeenCalledOnce(); + upload.bitmapStore.dispose(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvasProjectMutationPort.ts b/invokeai/frontend/webv2/src/workbench/canvasProjectMutationPort.ts new file mode 100644 index 00000000000..bcab928703f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasProjectMutationPort.ts @@ -0,0 +1,30 @@ +import type { CanvasProjectMutation } from './canvasProjectMutations'; +import type { CanvasStateContractV2 } from './types'; +import type { WorkbenchStore } from './workbenchStore'; + +export interface CanvasProjectMutationPort { + getCanvasState(): CanvasStateContractV2 | null; + subscribe(listener: () => void): () => void; + dispatch(mutation: CanvasProjectMutation): boolean; +} + +export const createCanvasProjectMutationPort = ( + store: Pick, + projectId: string +): CanvasProjectMutationPort => { + const getCanvasState = (): CanvasStateContractV2 | null => + store.getState().projects.find((project) => project.id === projectId)?.canvas ?? null; + + return { + dispatch: (mutation) => { + const before = getCanvasState(); + if (!before) { + return false; + } + store.dispatch({ mutation, projectId, type: 'applyCanvasProjectMutation' }); + return getCanvasState() !== before; + }, + getCanvasState, + subscribe: store.subscribe, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvasProjectMutations.ts b/invokeai/frontend/webv2/src/workbench/canvasProjectMutations.ts new file mode 100644 index 00000000000..c8b54b290b0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasProjectMutations.ts @@ -0,0 +1,727 @@ +import type { + CanvasAdjustmentsContract, + CanvasControlAdapterContract, + CanvasControlLayerContract, + CanvasDocumentContractV2, + CanvasLayerBaseContract, + CanvasLayerContract, + CanvasLayerSourceContract, + CanvasMaskContract, + CanvasRasterLayerContractV2, + CanvasRegionalGuidanceLayerContract, + ProjectEvent, + CanvasStagingAreaContractV2, + CanvasStateContractV2, + Project, +} from './types'; + +import { normalizeCanvasDocumentControlAdapters } from './canvasMigration'; +import { + getCanvasStagingCandidateFingerprint, + getCanvasStagingSlotCount, + getCanvasStagingSlots, +} from './canvasStagingView'; + +export type CanvasLayerBasePatch = Partial< + Pick +> & { transform?: Partial }; + +export type CanvasLayerConfigPatch = + | { + layerType: 'raster'; + adjustments?: CanvasAdjustmentsContract; + isTransparencyLocked?: boolean; + filter?: CanvasRasterLayerContractV2['filter']; + } + | { + layerType: 'control'; + adapter?: Partial; + withTransparencyEffect?: boolean; + filter?: CanvasControlLayerContract['filter']; + } + | { + layerType: 'regional_guidance'; + mask?: Partial; + positivePrompt?: string | null; + negativePrompt?: string | null; + autoNegative?: boolean; + referenceImages?: CanvasRegionalGuidanceLayerContract['referenceImages']; + } + | { layerType: 'inpaint_mask'; mask?: Partial; noiseLevel?: number; denoiseLimit?: number }; + +export type CanvasProjectMutation = + | { + type: 'commitStagedImage'; + candidateFingerprint: string; + event: ProjectEvent; + layer: CanvasRasterLayerContractV2; + selectedImageIndex: number; + } + | { + type: 'rollbackStagedImageCommit'; + event: ProjectEvent; + layer: CanvasRasterLayerContractV2; + selectedLayerId: string | null; + stagingArea: CanvasStagingAreaContractV2; + } + | { type: 'setStagedImageIndex'; imageIndex: number } + | { type: 'cycleStagedImage'; direction: -1 | 1 } + | { type: 'discardSelectedStagedImage' } + | { type: 'discardAllStagedImages' } + | { type: 'toggleCanvasStagingVisibility' } + | { type: 'toggleCanvasStagingThumbnailsVisibility' } + | { type: 'clearCanvasStaging' } + | { type: 'addCanvasLayer'; layer: CanvasLayerContract; index?: number } + | { + type: 'applyCanvasLayerStackMutation'; + add?: { index: number; layers: readonly CanvasLayerContract[] }; + removeIds?: readonly string[]; + enabledUpdates: readonly { id: string; isEnabled: boolean }[]; + selectedLayerId: string | null; + } + | { type: 'removeCanvasLayers'; ids: string[] } + | { type: 'duplicateCanvasLayer'; sourceId: string; newId: string } + | { type: 'reorderCanvasLayers'; orderedIds: string[] } + | { type: 'updateCanvasLayer'; id: string; patch: CanvasLayerBasePatch } + | { type: 'replaceCanvasLayer'; layerId: string; layer: CanvasLayerContract } + | { type: 'setCanvasLayersEnabled'; updates: readonly { id: string; isEnabled: boolean }[] } + | { type: 'updateCanvasLayerSource'; id: string; source: CanvasLayerSourceContract } + | { type: 'updateCanvasLayerConfig'; id: string; config: CanvasLayerConfigPatch } + | { type: 'convertCanvasLayer'; id: string; targetType: CanvasLayerContract['type']; layer: CanvasLayerContract } + | { + type: 'mergeCanvasLayersDown'; + upperLayerId: string; + source: Extract; + } + | { type: 'setCanvasBbox'; bbox: CanvasDocumentContractV2['bbox'] } + | { type: 'setCanvasSelectedLayer'; id: string | null } + | { type: 'resizeCanvasDocument'; width: number; height: number; offsetX?: number; offsetY?: number } + | { type: 'replaceCanvasDocument'; document: CanvasDocumentContractV2 } + | { type: 'saveCanvasSnapshot'; id: string; name: string; createdAt: string } + | { type: 'restoreCanvasSnapshot'; snapshotId: string } + | { type: 'deleteCanvasSnapshot'; snapshotId: string } + | { type: 'setCanvasStagingAutoSwitch'; mode: CanvasStagingAreaContractV2['autoSwitchMode'] }; + +const CANVAS_PROJECT_MUTATION_TYPES: ReadonlySet = new Set([ + 'commitStagedImage', + 'rollbackStagedImageCommit', + 'addCanvasLayer', + 'applyCanvasLayerStackMutation', + 'clearCanvasStaging', + 'convertCanvasLayer', + 'cycleStagedImage', + 'deleteCanvasSnapshot', + 'discardAllStagedImages', + 'discardSelectedStagedImage', + 'duplicateCanvasLayer', + 'mergeCanvasLayersDown', + 'removeCanvasLayers', + 'reorderCanvasLayers', + 'replaceCanvasDocument', + 'replaceCanvasLayer', + 'resizeCanvasDocument', + 'restoreCanvasSnapshot', + 'saveCanvasSnapshot', + 'setCanvasBbox', + 'setCanvasLayersEnabled', + 'setCanvasSelectedLayer', + 'setCanvasStagingAutoSwitch', + 'setStagedImageIndex', + 'toggleCanvasStagingThumbnailsVisibility', + 'toggleCanvasStagingVisibility', + 'updateCanvasLayer', + 'updateCanvasLayerConfig', + 'updateCanvasLayerSource', +]); + +export const isCanvasProjectMutation = (value: { type: string }): value is CanvasProjectMutation => + CANVAS_PROJECT_MUTATION_TYPES.has(value.type); + +type CanvasLayers = CanvasDocumentContractV2['layers']; + +const layerExists = (layers: CanvasLayers, id: string): boolean => layers.some((layer) => layer.id === id); + +const AUTO_LAYER_NAME_PATTERN = /^Layer (\d+)$/; + +export const nextLayerName = (existingNames: readonly string[]): string => { + const used = new Set(); + for (const name of existingNames) { + const match = AUTO_LAYER_NAME_PATTERN.exec(name.trim()); + if (match) { + const n = Number(match[1]); + if (Number.isInteger(n) && n > 0) { + used.add(n); + } + } + } + let n = 1; + while (used.has(n)) { + n += 1; + } + return `Layer ${n}`; +}; + +const repairSelectedLayerId = (document: CanvasDocumentContractV2): CanvasDocumentContractV2 => + document.selectedLayerId === null || layerExists(document.layers, document.selectedLayerId) + ? document + : { ...document, selectedLayerId: document.layers[0]?.id ?? null }; + +const setCanvasDocument = (project: Project, document: CanvasDocumentContractV2): Project => + document === project.canvas.document ? project : { ...project, canvas: { ...project.canvas, document } }; + +const updateCanvasDocument = ( + project: Project, + update: (document: CanvasDocumentContractV2) => CanvasDocumentContractV2 +): Project => setCanvasDocument(project, update(project.canvas.document)); + +const setCanvasState = (project: Project, canvas: CanvasStateContractV2): Project => + canvas === project.canvas ? project : { ...project, canvas }; + +const mapCanvasLayer = ( + document: CanvasDocumentContractV2, + id: string, + update: (layer: CanvasLayerContract) => CanvasLayerContract +): CanvasDocumentContractV2 => { + let changed = false; + const layers = document.layers.map((layer) => { + if (layer.id !== id) { + return layer; + } + const next = update(layer); + changed ||= next !== layer; + return next; + }); + return changed ? { ...document, layers } : document; +}; + +const setCanvasLayersEnabled = ( + document: CanvasDocumentContractV2, + updates: readonly { id: string; isEnabled: boolean }[] +): CanvasDocumentContractV2 => { + const targets = new Map(updates.map((update) => [update.id, update.isEnabled])); + let changed = false; + const layers = document.layers.map((layer) => { + const isEnabled = targets.get(layer.id); + if (isEnabled === undefined || isEnabled === layer.isEnabled) { + return layer; + } + changed = true; + return { ...layer, isEnabled }; + }); + return changed ? { ...document, layers } : document; +}; + +const applyLayerStackMutation = ( + document: CanvasDocumentContractV2, + mutation: Extract +): CanvasDocumentContractV2 => { + const currentIds = new Set(document.layers.map((layer) => layer.id)); + if (mutation.add && (mutation.removeIds?.length ?? 0) > 0) { + return document; + } + const removeIds = new Set(mutation.removeIds ?? []); + if ([...removeIds].some((id) => !currentIds.has(id))) { + return document; + } + const projectedIds = new Set(currentIds); + for (const id of removeIds) { + projectedIds.delete(id); + } + if (mutation.add) { + for (const layer of mutation.add.layers) { + if (projectedIds.has(layer.id)) { + return document; + } + projectedIds.add(layer.id); + } + } + if ( + mutation.enabledUpdates.some((update) => !projectedIds.has(update.id)) || + (mutation.selectedLayerId !== null && !projectedIds.has(mutation.selectedLayerId)) + ) { + return document; + } + let layers = document.layers; + let changed = false; + if (mutation.add?.layers.length) { + const index = Math.min(Math.max(0, Math.round(mutation.add.index)), layers.length); + layers = [...layers.slice(0, index), ...mutation.add.layers, ...layers.slice(index)]; + changed = true; + } + if (removeIds.size > 0) { + layers = layers.filter((layer) => !removeIds.has(layer.id)); + changed = true; + } + const enabledById = new Map(mutation.enabledUpdates.map((update) => [update.id, update.isEnabled])); + let enabledChanged = false; + const nextLayers = layers.map((layer) => { + const isEnabled = enabledById.get(layer.id); + if (isEnabled === undefined || isEnabled === layer.isEnabled) { + return layer; + } + enabledChanged = true; + changed = true; + return { ...layer, isEnabled }; + }); + if (enabledChanged) { + layers = nextLayers; + } + changed ||= document.selectedLayerId !== mutation.selectedLayerId; + return changed ? { ...document, layers, selectedLayerId: mutation.selectedLayerId } : document; +}; + +const addLayer = (document: CanvasDocumentContractV2, layer: CanvasLayerContract, index = 0) => { + const insertIndex = Math.min(Math.max(0, Math.round(index)), document.layers.length); + return { + ...document, + layers: [...document.layers.slice(0, insertIndex), layer, ...document.layers.slice(insertIndex)], + selectedLayerId: layer.id, + }; +}; + +const removeLayers = (document: CanvasDocumentContractV2, ids: readonly string[]): CanvasDocumentContractV2 => { + const removed = new Set(ids); + const layers = document.layers.filter((layer) => !removed.has(layer.id)); + if (layers.length === document.layers.length) { + return document; + } + let selectedLayerId = document.selectedLayerId; + if (selectedLayerId !== null && removed.has(selectedLayerId)) { + const removedIndex = document.layers.findIndex((layer) => layer.id === selectedLayerId); + const remainingIds = new Set(layers.map((layer) => layer.id)); + selectedLayerId = null; + for (let index = removedIndex + 1; index < document.layers.length; index += 1) { + const id = document.layers[index]?.id; + if (id && remainingIds.has(id)) { + selectedLayerId = id; + break; + } + } + if (selectedLayerId === null) { + for (let index = removedIndex - 1; index >= 0; index -= 1) { + const id = document.layers[index]?.id; + if (id && remainingIds.has(id)) { + selectedLayerId = id; + break; + } + } + } + } + return { ...document, layers, selectedLayerId }; +}; + +const duplicateLayer = (document: CanvasDocumentContractV2, sourceId: string, newId: string) => { + const index = document.layers.findIndex((layer) => layer.id === sourceId); + if (index === -1) { + return document; + } + const source = document.layers[index] as CanvasLayerContract; + const duplicate = structuredClone(source); + duplicate.id = newId; + duplicate.name = `${source.name} copy`; + return { + ...document, + layers: [...document.layers.slice(0, index), duplicate, ...document.layers.slice(index)], + selectedLayerId: newId, + }; +}; + +const reorderLayers = (document: CanvasDocumentContractV2, orderedIds: readonly string[]) => { + if (orderedIds.length !== document.layers.length || new Set(orderedIds).size !== orderedIds.length) { + return document; + } + const byId = new Map(document.layers.map((layer) => [layer.id, layer])); + const layers: CanvasLayers = []; + for (const id of orderedIds) { + const layer = byId.get(id); + if (!layer) { + return document; + } + layers.push(layer); + } + return { ...document, layers }; +}; + +const patchLayer = (layer: CanvasLayerContract, patch: CanvasLayerBasePatch): CanvasLayerContract => { + const { transform, ...rest } = patch; + return { ...layer, ...rest, transform: transform ? { ...layer.transform, ...transform } : layer.transform }; +}; + +const patchLayerConfig = (layer: CanvasLayerContract, config: CanvasLayerConfigPatch): CanvasLayerContract => { + if (layer.type !== config.layerType) { + return layer; + } + if (layer.type === 'raster' && config.layerType === 'raster') { + return { + ...layer, + ...(Object.hasOwn(config, 'adjustments') ? { adjustments: config.adjustments } : {}), + ...(Object.hasOwn(config, 'isTransparencyLocked') ? { isTransparencyLocked: config.isTransparencyLocked } : {}), + ...(Object.hasOwn(config, 'filter') ? { filter: config.filter } : {}), + }; + } + if (layer.type === 'control' && config.layerType === 'control') { + return { + ...layer, + ...(config.adapter ? { adapter: { ...layer.adapter, ...config.adapter } } : {}), + ...(Object.hasOwn(config, 'withTransparencyEffect') + ? { withTransparencyEffect: config.withTransparencyEffect } + : {}), + ...(Object.hasOwn(config, 'filter') ? { filter: config.filter } : {}), + }; + } + if (layer.type === 'regional_guidance' && config.layerType === 'regional_guidance') { + return { + ...layer, + ...(config.mask ? { mask: { ...layer.mask, ...config.mask } } : {}), + ...(Object.hasOwn(config, 'positivePrompt') ? { positivePrompt: config.positivePrompt } : {}), + ...(Object.hasOwn(config, 'negativePrompt') ? { negativePrompt: config.negativePrompt } : {}), + ...(Object.hasOwn(config, 'autoNegative') ? { autoNegative: config.autoNegative } : {}), + ...(Object.hasOwn(config, 'referenceImages') ? { referenceImages: config.referenceImages } : {}), + }; + } + if (layer.type === 'inpaint_mask' && config.layerType === 'inpaint_mask') { + return { + ...layer, + ...(config.mask ? { mask: { ...layer.mask, ...config.mask } } : {}), + ...(Object.hasOwn(config, 'noiseLevel') ? { noiseLevel: config.noiseLevel } : {}), + ...(Object.hasOwn(config, 'denoiseLimit') ? { denoiseLimit: config.denoiseLimit } : {}), + }; + } + return layer; +}; + +const clampBbox = (bbox: CanvasDocumentContractV2['bbox'], width: number, height: number) => { + const clampedWidth = Math.min(Math.max(1, Math.round(bbox.width)), width); + const clampedHeight = Math.min(Math.max(1, Math.round(bbox.height)), height); + return { + height: clampedHeight, + width: clampedWidth, + x: Math.min(Math.max(0, Math.round(bbox.x)), width - clampedWidth), + y: Math.min(Math.max(0, Math.round(bbox.y)), height - clampedHeight), + }; +}; + +const clearStagingArea = (stagingArea: CanvasStateContractV2['stagingArea']) => ({ + ...stagingArea, + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + sourceQueueItemId: undefined, +}); + +const clampStagedImageIndex = (imageIndex: number, slotCount: number): number => + Math.min(Math.max(0, slotCount - 1), Math.max(0, imageIndex)); + +const selectedCandidate = (project: Project) => { + const slot = getCanvasStagingSlots(project.canvas, project.queue.items)[ + project.canvas.stagingArea.selectedImageIndex + ]; + return slot?.kind === 'candidate' ? slot.candidate : undefined; +}; + +export const applyCanvasProjectMutation = (project: Project, mutation: CanvasProjectMutation): Project => { + switch (mutation.type) { + case 'commitStagedImage': { + const stagedImage = selectedCandidate(project); + if ( + project.canvas.stagingArea.selectedImageIndex !== mutation.selectedImageIndex || + !stagedImage || + getCanvasStagingCandidateFingerprint(stagedImage) !== mutation.candidateFingerprint + ) { + return project; + } + const { layer } = mutation; + if (project.canvas.document.layers.some((candidate) => candidate.id === layer.id)) { + return project; + } + return { + ...project, + canvas: { + ...project.canvas, + document: { + ...project.canvas.document, + layers: [layer, ...project.canvas.document.layers], + selectedLayerId: layer.id, + }, + stagingArea: clearStagingArea(project.canvas.stagingArea), + }, + events: [mutation.event, ...project.events], + }; + } + case 'rollbackStagedImageCommit': { + if ( + project.canvas.document.selectedLayerId !== mutation.layer.id || + project.canvas.document.layers[0] !== mutation.layer || + project.events[0] !== mutation.event || + project.canvas.stagingArea.pendingImages.length !== 0 + ) { + return project; + } + return { + ...project, + canvas: { + ...project.canvas, + document: { + ...project.canvas.document, + layers: project.canvas.document.layers.slice(1), + selectedLayerId: mutation.selectedLayerId, + }, + stagingArea: mutation.stagingArea, + }, + events: project.events.slice(1), + }; + } + case 'setStagedImageIndex': { + const selectedImageIndex = clampStagedImageIndex( + mutation.imageIndex, + getCanvasStagingSlotCount(project.canvas, project.queue.items) + ); + return selectedImageIndex === project.canvas.stagingArea.selectedImageIndex + ? project + : { + ...project, + canvas: { + ...project.canvas, + stagingArea: { ...project.canvas.stagingArea, selectedImageIndex }, + }, + }; + } + case 'cycleStagedImage': { + const count = getCanvasStagingSlotCount(project.canvas, project.queue.items); + const current = project.canvas.stagingArea.selectedImageIndex; + const selectedImageIndex = count < 2 ? 0 : (current + mutation.direction + count) % count; + return selectedImageIndex === current + ? project + : { + ...project, + canvas: { + ...project.canvas, + stagingArea: { ...project.canvas.stagingArea, selectedImageIndex }, + }, + }; + } + case 'discardSelectedStagedImage': { + const selected = selectedCandidate(project); + if (!selected) { + return project; + } + const pendingImages = project.canvas.stagingArea.pendingImages.filter( + (image) => image.sourceQueueItemId !== selected.sourceQueueItemId || image.imageName !== selected.imageName + ); + const canvas = { + ...project.canvas, + stagingArea: { + ...project.canvas.stagingArea, + pendingImageIds: pendingImages.map((image) => image.imageName), + pendingImages, + }, + }; + const slotCount = getCanvasStagingSlotCount(canvas, project.queue.items); + return { + ...project, + canvas: { + ...canvas, + stagingArea: { + ...canvas.stagingArea, + isVisible: slotCount > 0 && canvas.stagingArea.isVisible, + selectedImageIndex: clampStagedImageIndex(canvas.stagingArea.selectedImageIndex, slotCount), + sourceQueueItemId: pendingImages.length > 0 ? canvas.stagingArea.sourceQueueItemId : undefined, + }, + }, + }; + } + case 'discardAllStagedImages': { + const canvas = { ...project.canvas, stagingArea: clearStagingArea(project.canvas.stagingArea) }; + return { + ...project, + canvas: { + ...canvas, + stagingArea: { + ...canvas.stagingArea, + isVisible: getCanvasStagingSlotCount(canvas, project.queue.items) > 0, + }, + }, + }; + } + case 'toggleCanvasStagingVisibility': + return getCanvasStagingSlotCount(project.canvas, project.queue.items) === 0 + ? project + : { + ...project, + canvas: { + ...project.canvas, + stagingArea: { + ...project.canvas.stagingArea, + isVisible: !project.canvas.stagingArea.isVisible, + }, + }, + }; + case 'toggleCanvasStagingThumbnailsVisibility': + return getCanvasStagingSlotCount(project.canvas, project.queue.items) === 0 + ? project + : { + ...project, + canvas: { + ...project.canvas, + stagingArea: { + ...project.canvas.stagingArea, + areThumbnailsVisible: !project.canvas.stagingArea.areThumbnailsVisible, + }, + }, + }; + case 'clearCanvasStaging': + return { ...project, canvas: { ...project.canvas, stagingArea: clearStagingArea(project.canvas.stagingArea) } }; + case 'addCanvasLayer': + return updateCanvasDocument(project, (document) => addLayer(document, mutation.layer, mutation.index)); + case 'applyCanvasLayerStackMutation': + return updateCanvasDocument(project, (document) => applyLayerStackMutation(document, mutation)); + case 'removeCanvasLayers': + return updateCanvasDocument(project, (document) => removeLayers(document, mutation.ids)); + case 'duplicateCanvasLayer': + return updateCanvasDocument(project, (document) => duplicateLayer(document, mutation.sourceId, mutation.newId)); + case 'reorderCanvasLayers': + return updateCanvasDocument(project, (document) => reorderLayers(document, mutation.orderedIds)); + case 'updateCanvasLayer': + return updateCanvasDocument(project, (document) => + mapCanvasLayer(document, mutation.id, (layer) => patchLayer(layer, mutation.patch)) + ); + case 'replaceCanvasLayer': + return updateCanvasDocument(project, (document) => + mapCanvasLayer(document, mutation.layerId, () => mutation.layer) + ); + case 'setCanvasLayersEnabled': + return updateCanvasDocument(project, (document) => setCanvasLayersEnabled(document, mutation.updates)); + case 'updateCanvasLayerSource': + return updateCanvasDocument(project, (document) => + mapCanvasLayer(document, mutation.id, (layer) => + layer.type === 'raster' || layer.type === 'control' ? { ...layer, source: mutation.source } : layer + ) + ); + case 'updateCanvasLayerConfig': + return updateCanvasDocument(project, (document) => + mapCanvasLayer(document, mutation.id, (layer) => patchLayerConfig(layer, mutation.config)) + ); + case 'convertCanvasLayer': { + if (mutation.layer.type !== mutation.targetType || !layerExists(project.canvas.document.layers, mutation.id)) { + return project; + } + const converted = structuredClone(mutation.layer); + converted.id = mutation.id; + return updateCanvasDocument(project, (document) => mapCanvasLayer(document, mutation.id, () => converted)); + } + case 'mergeCanvasLayersDown': { + const document = project.canvas.document; + const upperIndex = document.layers.findIndex((layer) => layer.id === mutation.upperLayerId); + const below = upperIndex === -1 ? undefined : document.layers[upperIndex + 1]; + if (!below) { + return project; + } + const merged: CanvasRasterLayerContractV2 = { + blendMode: below.blendMode, + id: below.id, + isEnabled: below.isEnabled, + isLocked: below.isLocked, + name: below.name, + opacity: below.opacity, + source: mutation.source, + transform: below.transform, + type: 'raster', + }; + return setCanvasDocument(project, { + ...document, + layers: document.layers + .filter((_, index) => index !== upperIndex) + .map((layer) => (layer.id === below.id ? merged : layer)), + selectedLayerId: document.selectedLayerId === mutation.upperLayerId ? below.id : document.selectedLayerId, + }); + } + case 'setCanvasBbox': + return updateCanvasDocument(project, (document) => ({ + ...document, + bbox: { + height: Math.max(1, Math.round(mutation.bbox.height)), + width: Math.max(1, Math.round(mutation.bbox.width)), + x: Math.round(mutation.bbox.x), + y: Math.round(mutation.bbox.y), + }, + })); + case 'setCanvasSelectedLayer': + return updateCanvasDocument(project, (document) => + mutation.id !== null && !layerExists(document.layers, mutation.id) + ? document + : document.selectedLayerId === mutation.id + ? document + : { ...document, selectedLayerId: mutation.id } + ); + case 'resizeCanvasDocument': { + const width = Math.max(1, Math.round(mutation.width)); + const height = Math.max(1, Math.round(mutation.height)); + const offsetX = mutation.offsetX ?? 0; + const offsetY = mutation.offsetY ?? 0; + return updateCanvasDocument(project, (document) => ({ + ...document, + bbox: clampBbox( + { ...document.bbox, x: document.bbox.x + offsetX, y: document.bbox.y + offsetY }, + width, + height + ), + height, + layers: + offsetX === 0 && offsetY === 0 + ? document.layers + : document.layers.map((layer) => ({ + ...layer, + transform: { ...layer.transform, x: layer.transform.x + offsetX, y: layer.transform.y + offsetY }, + })), + width, + })); + } + case 'replaceCanvasDocument': + return setCanvasState(project, { + ...project.canvas, + document: repairSelectedLayerId(normalizeCanvasDocumentControlAdapters(structuredClone(mutation.document))), + documentRevision: project.canvas.documentRevision + 1, + stagingArea: clearStagingArea(project.canvas.stagingArea), + }); + case 'saveCanvasSnapshot': + return setCanvasState(project, { + ...project.canvas, + snapshots: [ + ...project.canvas.snapshots, + { + createdAt: mutation.createdAt, + document: normalizeCanvasDocumentControlAdapters(structuredClone(project.canvas.document)), + id: mutation.id, + name: mutation.name, + }, + ], + }); + case 'restoreCanvasSnapshot': { + const snapshot = project.canvas.snapshots.find((entry) => entry.id === mutation.snapshotId); + return snapshot + ? setCanvasState(project, { + ...project.canvas, + document: repairSelectedLayerId(normalizeCanvasDocumentControlAdapters(structuredClone(snapshot.document))), + documentRevision: project.canvas.documentRevision + 1, + }) + : project; + } + case 'deleteCanvasSnapshot': { + const snapshots = project.canvas.snapshots.filter((entry) => entry.id !== mutation.snapshotId); + return snapshots.length === project.canvas.snapshots.length + ? project + : setCanvasState(project, { ...project.canvas, snapshots }); + } + case 'setCanvasStagingAutoSwitch': + return project.canvas.stagingArea.autoSwitchMode === mutation.mode + ? project + : { + ...project, + canvas: { + ...project.canvas, + stagingArea: { ...project.canvas.stagingArea, autoSwitchMode: mutation.mode }, + }, + }; + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvasStagingView.test.ts b/invokeai/frontend/webv2/src/workbench/canvasStagingView.test.ts new file mode 100644 index 00000000000..ff75681d120 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasStagingView.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from 'vitest'; + +import type { CanvasStagingCandidateContract, GeneratedImageContract, QueueItem, QueueItemStatus } from './types'; + +import { createEmptyCanvasStateV2 } from './canvasMigration'; +import { + getCancelableCanvasStagingQueueItemId, + getCanvasQueuePlaceholderSlots, + getCanvasStagingCandidateFingerprint, + getCanvasStagingSlots, +} from './canvasStagingView'; + +const createImage = (imageName: string, sourceQueueItemId: string): GeneratedImageContract => ({ + height: 768, + imageName, + imageUrl: `/api/v1/images/i/${imageName}/full`, + queuedAt: '2026-06-09T00:00:00.000Z', + sourceQueueItemId, + thumbnailUrl: `/api/v1/images/i/${imageName}/thumbnail`, + width: 512, +}); + +const createCandidate = (imageName: string, sourceQueueItemId: string): CanvasStagingCandidateContract => ({ + ...createImage(imageName, sourceQueueItemId), + placement: { height: 256, opacity: 1, width: 256, x: 16, y: 24 }, +}); + +const createBackendCandidate = ( + imageName: string, + sourceQueueItemId: string, + sourceBackendItemId: number +): CanvasStagingCandidateContract => ({ + ...createCandidate(imageName, sourceQueueItemId), + sourceBackendItemId, +}); + +const createCanvas = ({ + bbox = { height: 512, width: 512, x: 0, y: 0 }, + revision = 0, + stagedImages = [], +}: { + bbox?: { height: number; width: number; x: number; y: number }; + revision?: number; + stagedImages?: CanvasStagingCandidateContract[]; +} = {}) => { + const canvas = createEmptyCanvasStateV2(1024, 1024); + + return { + ...canvas, + document: { ...canvas.document, bbox }, + documentRevision: revision, + stagingArea: { + ...canvas.stagingArea, + isVisible: stagedImages.length > 0, + pendingImageIds: stagedImages.map((image) => image.imageName), + pendingImages: stagedImages, + }, + }; +}; + +const createQueueItem = ({ + backendItemIds, + batchCount = 1, + bbox = { height: 320, width: 640, x: 10, y: 12 }, + cancelledBackendItemIds, + completedBackendItemIds, + destination = 'canvas', + id, + revision = 0, + status = 'pending', + submittedAt = '2026-06-09T00:00:00.000Z', +}: { + backendItemIds?: number[]; + batchCount?: number; + bbox?: { height: number; width: number; x: number; y: number }; + cancelledBackendItemIds?: number[]; + completedBackendItemIds?: number[]; + destination?: 'canvas' | 'gallery'; + id: string; + revision?: number; + status?: QueueItemStatus; + submittedAt?: string; +}): QueueItem => + ({ + backendItemIds, + cancellable: true, + cancelledBackendItemIds, + completedBackendItemIds, + id, + snapshot: { + canvas: createCanvas({ bbox, revision }), + destination, + graph: { edges: [], id: 'graph', label: 'Graph', nodes: [], updatedAt: '2026-06-09T00:00:00.000Z', version: 1 }, + sourceId: 'canvas', + submittedAt, + widgetInstances: {}, + widgetStates: { + generate: { id: 'generate', label: 'Generate', values: { batchCount, height: 999, width: 999 }, version: 1 }, + }, + }, + status, + }) as QueueItem; + +describe('canvas staging view', () => { + it('derives in-flight canvas placeholders from the submitted bbox without persisting fake images', () => { + const canvas = createCanvas({ revision: 2 }); + const queueItems = [ + createQueueItem({ + batchCount: 2, + bbox: { height: 384, width: 640, x: 8, y: 9 }, + id: 'q-pending', + revision: 2, + submittedAt: '2026-06-09T00:00:01.000Z', + }), + createQueueItem({ + backendItemIds: [11, 12, 13], + bbox: { height: 256, width: 512, x: 1, y: 2 }, + cancelledBackendItemIds: [12], + completedBackendItemIds: [11], + id: 'q-running', + revision: 2, + status: 'running', + submittedAt: '2026-06-09T00:00:02.000Z', + }), + ]; + + expect(getCanvasQueuePlaceholderSlots(canvas, queueItems)).toEqual([ + { height: 384, id: 'q-pending:0', itemIndex: 1, kind: 'placeholder', queueItemId: 'q-pending', width: 640 }, + { height: 384, id: 'q-pending:1', itemIndex: 2, kind: 'placeholder', queueItemId: 'q-pending', width: 640 }, + { height: 256, id: 'q-running:2', itemIndex: 3, kind: 'placeholder', queueItemId: 'q-running', width: 512 }, + ]); + expect(canvas.stagingArea.pendingImages).toEqual([]); + }); + + it('skips gallery, stale, terminal, completed, and cancelled canvas slots', () => { + const canvas = createCanvas({ revision: 2 }); + const queueItems = [ + createQueueItem({ destination: 'gallery', id: 'q-gallery', revision: 2 }), + createQueueItem({ id: 'q-stale', revision: 1 }), + createQueueItem({ id: 'q-completed', revision: 2, status: 'completed' }), + createQueueItem({ id: 'q-cancelled', revision: 2, status: 'cancelled' }), + createQueueItem({ + backendItemIds: [11, 12], + cancelledBackendItemIds: [12], + completedBackendItemIds: [11], + id: 'q-no-open-slots', + revision: 2, + status: 'running', + }), + ]; + + expect(getCanvasQueuePlaceholderSlots(canvas, queueItems)).toEqual([]); + }); + + it('combines ready staged candidates with ephemeral placeholders in one selected slot list', () => { + const staged = createCandidate('ready.png', 'q-ready'); + const canvas = createCanvas({ revision: 2, stagedImages: [staged] }); + const queueItems = [createQueueItem({ id: 'q-pending', revision: 2 })]; + + expect(getCanvasStagingSlots(canvas, queueItems)).toEqual([ + { + candidate: staged, + height: 768, + id: `candidate:${getCanvasStagingCandidateFingerprint(staged)}`, + imageName: 'ready.png', + kind: 'candidate', + queueItemId: 'q-ready', + width: 512, + }, + { height: 320, id: 'q-pending:0', itemIndex: 1, kind: 'placeholder', queueItemId: 'q-pending', width: 640 }, + ]); + }); + + it('orders placeholders by execution order even when local queue items are newest-first', () => { + const canvas = createCanvas({ revision: 2 }); + const queueItems = [ + createQueueItem({ id: 'q-third', revision: 2, submittedAt: '2026-06-09T00:00:03.000Z' }), + createQueueItem({ id: 'q-second', revision: 2, submittedAt: '2026-06-09T00:00:02.000Z' }), + createQueueItem({ id: 'q-first', revision: 2, submittedAt: '2026-06-09T00:00:01.000Z' }), + ]; + + expect(getCanvasQueuePlaceholderSlots(canvas, queueItems).map((slot) => slot.queueItemId)).toEqual([ + 'q-first', + 'q-second', + 'q-third', + ]); + }); + + it('interleaves completed candidates and placeholders per queue item in execution order', () => { + const firstCandidate = createBackendCandidate('first-result.png', 'q-first', 11); + const canvas = createCanvas({ revision: 2, stagedImages: [firstCandidate] }); + const queueItems = [ + createQueueItem({ id: 'q-second', revision: 2, submittedAt: '2026-06-09T00:00:02.000Z' }), + createQueueItem({ + backendItemIds: [11, 12], + completedBackendItemIds: [11], + id: 'q-first', + revision: 2, + status: 'running', + submittedAt: '2026-06-09T00:00:01.000Z', + }), + ]; + + expect( + getCanvasStagingSlots(canvas, queueItems).map((slot) => + slot.kind === 'candidate' + ? `candidate:${slot.queueItemId}:${slot.itemIndex}` + : `placeholder:${slot.queueItemId}:${slot.itemIndex}` + ) + ).toEqual(['candidate:q-first:1', 'placeholder:q-first:2', 'placeholder:q-second:1']); + }); + + it('resolves a cancelable queue item only for placeholder slots', () => { + const candidate = createCandidate('ready.png', 'q-ready'); + + expect( + getCancelableCanvasStagingQueueItemId({ + candidate, + height: 768, + id: 'candidate:q-ready:ready.png', + imageName: 'ready.png', + kind: 'candidate', + queueItemId: 'q-ready', + width: 512, + }) + ).toBeNull(); + expect( + getCancelableCanvasStagingQueueItemId({ + height: 320, + id: 'q-pending:0', + itemIndex: 1, + kind: 'placeholder', + queueItemId: 'q-pending', + width: 640, + }) + ).toBe('q-pending'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvasStagingView.ts b/invokeai/frontend/webv2/src/workbench/canvasStagingView.ts new file mode 100644 index 00000000000..c9c264c7ac0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasStagingView.ts @@ -0,0 +1,261 @@ +import type { CanvasStagingCandidateContract, CanvasStateContractV2, QueueItem } from './types'; + +import { sanitizeBatchCount } from './generation/batch'; + +export interface CanvasQueuePlaceholderSlot { + height: number; + id: string; + itemIndex: number; + kind: 'placeholder'; + queueItemId: string; + width: number; +} + +export interface CanvasCandidateSlot { + candidate: CanvasStagingCandidateContract; + height: number; + id: string; + imageName: string; + itemIndex?: number; + kind: 'candidate'; + queueItemId: string; + width: number; +} + +export type CanvasStagingSlot = CanvasCandidateSlot | CanvasQueuePlaceholderSlot; + +const isActiveCanvasQueueItemForDocument = (canvas: CanvasStateContractV2, item: QueueItem): boolean => + item.snapshot.destination === 'canvas' && + (item.status === 'pending' || item.status === 'running') && + item.snapshot.canvas.documentRevision === canvas.documentRevision; + +const getFiniteNumber = (value: unknown, fallback: number): number => + typeof value === 'number' && Number.isFinite(value) ? value : fallback; + +const getSubmittedBboxSize = (item: QueueItem): { height: number; width: number } => { + const { bbox } = item.snapshot.canvas.document; + + return { + height: Math.max(1, Math.round(getFiniteNumber(bbox.height, item.snapshot.canvas.document.height))), + width: Math.max(1, Math.round(getFiniteNumber(bbox.width, item.snapshot.canvas.document.width))), + }; +}; + +const getExpectedImageCount = (item: QueueItem): number => { + if (item.backendItemIds?.length) { + return item.backendItemIds.length; + } + + return sanitizeBatchCount( + item.snapshot.generate?.values.batchCount ?? item.snapshot.widgetStates.generate?.values.batchCount + ); +}; + +/** Collision-safe candidate value identity used with the selected slot index for guarded acceptance. */ +export const getCanvasStagingCandidateFingerprint = (candidate: CanvasStagingCandidateContract): string => + JSON.stringify([ + candidate.sourceQueueItemId, + candidate.sourceBackendItemId ?? null, + candidate.imageName, + candidate.width, + candidate.height, + candidate.placement.x, + candidate.placement.y, + candidate.placement.width, + candidate.placement.height, + candidate.placement.opacity, + ]); + +const compareQueueItemsByExecutionOrder = (left: QueueItem, right: QueueItem): number => { + const leftBackendItemId = left.backendItemIds?.[0]; + const rightBackendItemId = right.backendItemIds?.[0]; + + if (leftBackendItemId !== undefined && rightBackendItemId !== undefined && leftBackendItemId !== rightBackendItemId) { + return leftBackendItemId - rightBackendItemId; + } + + const leftSubmittedAt = Date.parse(left.snapshot.submittedAt); + const rightSubmittedAt = Date.parse(right.snapshot.submittedAt); + + if (Number.isFinite(leftSubmittedAt) && Number.isFinite(rightSubmittedAt) && leftSubmittedAt !== rightSubmittedAt) { + return leftSubmittedAt - rightSubmittedAt; + } + + return 0; +}; + +const getQueueItemsInExecutionOrder = (queueItems: readonly QueueItem[]): QueueItem[] => + queueItems + .map((item, index) => ({ index, item })) + .sort((left, right) => compareQueueItemsByExecutionOrder(left.item, right.item) || right.index - left.index) + .map(({ item }) => item); + +const createCandidateSlot = (candidate: CanvasStagingCandidateContract, itemIndex?: number): CanvasCandidateSlot => ({ + candidate, + height: candidate.height, + id: `candidate:${getCanvasStagingCandidateFingerprint(candidate)}`, + imageName: candidate.imageName, + ...(itemIndex === undefined ? {} : { itemIndex }), + kind: 'candidate', + queueItemId: candidate.sourceQueueItemId, + width: candidate.width, +}); + +const createPlaceholderSlot = (item: QueueItem, itemIndex: number): CanvasQueuePlaceholderSlot => { + const { height, width } = getSubmittedBboxSize(item); + + return { + height, + id: `${item.id}:${itemIndex - 1}`, + itemIndex, + kind: 'placeholder', + queueItemId: item.id, + width, + }; +}; + +export const getCanvasQueuePlaceholderSlots = ( + canvas: CanvasStateContractV2, + queueItems: readonly QueueItem[] +): CanvasQueuePlaceholderSlot[] => { + const placeholders: CanvasQueuePlaceholderSlot[] = []; + + for (const item of getQueueItemsInExecutionOrder(queueItems)) { + if (!isActiveCanvasQueueItemForDocument(canvas, item)) { + continue; + } + + const completedBackendItemIds = new Set(item.completedBackendItemIds ?? []); + const cancelledBackendItemIds = new Set(item.cancelledBackendItemIds ?? []); + + if (item.backendItemIds?.length) { + for (let index = 0; index < item.backendItemIds.length; index += 1) { + const backendItemId = item.backendItemIds[index]; + + if ( + backendItemId === undefined || + completedBackendItemIds.has(backendItemId) || + cancelledBackendItemIds.has(backendItemId) + ) { + continue; + } + + placeholders.push(createPlaceholderSlot(item, index + 1)); + } + + continue; + } + + for (let index = 0; index < getExpectedImageCount(item); index += 1) { + placeholders.push(createPlaceholderSlot(item, index + 1)); + } + } + + return placeholders; +}; + +const getCandidatesByQueueItemId = ( + candidates: readonly CanvasStagingCandidateContract[] +): Map => { + const candidatesByQueueItemId = new Map(); + + for (const candidate of candidates) { + const candidatesForQueueItem = candidatesByQueueItemId.get(candidate.sourceQueueItemId) ?? []; + + candidatesForQueueItem.push(candidate); + candidatesByQueueItemId.set(candidate.sourceQueueItemId, candidatesForQueueItem); + } + + return candidatesByQueueItemId; +}; + +const getCanvasStagingSlotsForQueueItem = ( + canvas: CanvasStateContractV2, + item: QueueItem, + candidates: readonly CanvasStagingCandidateContract[] +): CanvasStagingSlot[] => { + const slots: CanvasStagingSlot[] = []; + const remainingCandidates = [...candidates]; + const isActive = isActiveCanvasQueueItemForDocument(canvas, item); + const completedBackendItemIds = new Set(item.completedBackendItemIds ?? []); + const cancelledBackendItemIds = new Set(item.cancelledBackendItemIds ?? []); + + if (item.backendItemIds?.length) { + for (let index = 0; index < item.backendItemIds.length; index += 1) { + const backendItemId = item.backendItemIds[index]; + const itemIndex = index + 1; + + if (backendItemId === undefined) { + continue; + } + + const candidateIndex = remainingCandidates.findIndex( + (candidate) => candidate.sourceBackendItemId === backendItemId + ); + + if (candidateIndex !== -1) { + const [candidate] = remainingCandidates.splice(candidateIndex, 1); + + if (candidate) { + slots.push(createCandidateSlot(candidate, itemIndex)); + } + + continue; + } + + if (isActive && !completedBackendItemIds.has(backendItemId) && !cancelledBackendItemIds.has(backendItemId)) { + slots.push(createPlaceholderSlot(item, itemIndex)); + } + } + + return [...slots, ...remainingCandidates.map((candidate) => createCandidateSlot(candidate))]; + } + + slots.push(...remainingCandidates.map((candidate, index) => createCandidateSlot(candidate, index + 1))); + + if (!isActive) { + return slots; + } + + for (let index = remainingCandidates.length; index < getExpectedImageCount(item); index += 1) { + slots.push(createPlaceholderSlot(item, index + 1)); + } + + return slots; +}; + +export const getCanvasStagingSlots = ( + canvas: CanvasStateContractV2, + queueItems: readonly QueueItem[] +): CanvasStagingSlot[] => { + const candidatesByQueueItemId = getCandidatesByQueueItemId(canvas.stagingArea.pendingImages); + const slots: CanvasStagingSlot[] = []; + + for (const item of getQueueItemsInExecutionOrder(queueItems)) { + const candidates = candidatesByQueueItemId.get(item.id) ?? []; + + if (!isActiveCanvasQueueItemForDocument(canvas, item) && candidates.length === 0) { + continue; + } + + slots.push(...getCanvasStagingSlotsForQueueItem(canvas, item, candidates)); + candidatesByQueueItemId.delete(item.id); + } + + const orphanCandidateSlots = [...candidatesByQueueItemId.values()].flatMap((candidates) => + candidates.map((candidate) => createCandidateSlot(candidate)) + ); + + return [...orphanCandidateSlots, ...slots]; +}; + +export const getCanvasStagingSlotCount = (canvas: CanvasStateContractV2, queueItems: readonly QueueItem[]): number => + getCanvasStagingSlots(canvas, queueItems).length; + +export const getFirstCanvasPlaceholderSlotIndex = ( + canvas: CanvasStateContractV2, + queueItems: readonly QueueItem[] +): number => getCanvasStagingSlots(canvas, queueItems).findIndex((slot) => slot.kind === 'placeholder'); + +export const getCancelableCanvasStagingQueueItemId = (slot: CanvasStagingSlot | undefined): string | null => + slot?.kind === 'placeholder' ? slot.queueItemId : null; diff --git a/invokeai/frontend/webv2/src/workbench/components/InvokeMark.tsx b/invokeai/frontend/webv2/src/workbench/components/InvokeMark.tsx new file mode 100644 index 00000000000..d77971e5ac4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/InvokeMark.tsx @@ -0,0 +1,14 @@ +import { Box } from '@chakra-ui/react'; + +/** The Invoke logomark, drawn in the theme brand color. */ +export const InvokeMark = ({ size = 36 }: { size?: number }) => ( + + + +); diff --git a/invokeai/frontend/webv2/src/workbench/components/QueueProgressIndicator.tsx b/invokeai/frontend/webv2/src/workbench/components/QueueProgressIndicator.tsx new file mode 100644 index 00000000000..8e1278eabdf --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/QueueProgressIndicator.tsx @@ -0,0 +1,65 @@ +import type { QueueProgressBarState } from '@workbench/queueSummary'; +import type { ComponentProps } from 'react'; + +import { Progress, ProgressCircle } from '@chakra-ui/react'; + +const getProgressValuePercent = (state: QueueProgressBarState): number | null => + state.kind === 'determinate' ? state.value * 100 : state.value; + +type ProgressCircleRootProps = ComponentProps; +type QueueCircularProgressSize = NonNullable | '2xs'; + +export const QueueCircularProgress = ({ + size = '2xs', + state, + ...props +}: Omit & { + size?: QueueCircularProgressSize; + state: QueueProgressBarState; +}) => { + if (state.kind === 'idle') { + return null; + } + + return ( + + + + + + + ); +}; + +export const QueueTabBackgroundProgress = ({ + state, + ...props +}: Omit, 'value'> & { state: QueueProgressBarState }) => { + if (state.kind === 'idle') { + return null; + } + + return ( + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/WorkbenchSplashScreen.tsx b/invokeai/frontend/webv2/src/workbench/components/WorkbenchSplashScreen.tsx new file mode 100644 index 00000000000..a247000b525 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/WorkbenchSplashScreen.tsx @@ -0,0 +1,46 @@ +import splashImageUrl from '@assets/SplashImage.webp'; +import { Box, Flex, Heading, HStack, Spinner, Text, VStack } from '@chakra-ui/react'; +import { InvokeMark } from '@workbench/components/InvokeMark'; +import { useTranslation } from 'react-i18next'; + +import { APP_VERSION } from '@/appMetadata'; + +const splashImageStyle = { backgroundPosition: 'center', backgroundSize: 'cover' } as const; + +export const WorkbenchSplashScreen = ({ messageKey = 'splash.loadingWorkspace' }: { messageKey?: string }) => { + const { t } = useTranslation(); + + return ( + + + + + + + + {t('app.nameWithVersion', { name: t('app.name'), version: APP_VERSION })} + {t('splash.tagline')} + {t('splash.artworkBy', { artist: 'John Smith' })} + + + + {t(messageKey)} + + + + {t('splash.copyright', { year: new Date().getFullYear() })} + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/Button.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/Button.tsx new file mode 100644 index 00000000000..29d6140be1f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/Button.tsx @@ -0,0 +1,30 @@ +import type { ComponentProps } from 'react'; + +import { + Button as ChakraButton, + CloseButton as ChakraCloseButton, + IconButton as ChakraIconButton, +} from '@chakra-ui/react'; + +type ButtonProps = ComponentProps; +type IconButtonProps = ComponentProps; +type CloseButtonProps = ComponentProps; + +/** + * Workbench buttons. Solid buttons default to the blue `accent` palette; every + * other variant stays on the neutral, theme-aware `gray` palette. Pass + * `colorPalette` explicitly to override (e.g. `brand` for the global Invoke + * action, `red` for destructive actions). + */ +const defaultPalette = (variant: ButtonProps['variant']): ButtonProps['colorPalette'] => + variant === undefined || variant === 'solid' ? 'accent' : 'gray'; + +export const Button = ({ colorPalette, ...props }: ButtonProps) => ( + +); + +export const IconButton = ({ colorPalette, ...props }: IconButtonProps) => ( + +); + +export const CloseButton = (props: CloseButtonProps) => ; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/ColorPicker.test.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/ColorPicker.test.tsx new file mode 100644 index 00000000000..4b12c8b3f6c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/ColorPicker.test.tsx @@ -0,0 +1,18 @@ +import { ChakraProvider } from '@chakra-ui/react'; +import { system } from '@theme/system'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { ColorPicker } from './ColorPicker'; + +describe('ColorPicker', () => { + it('does not mount its overlay until it is opened', () => { + const markup = renderToStaticMarkup( + + + + ); + + expect(markup).not.toContain('data-part="content"'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/ColorPicker.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/ColorPicker.tsx new file mode 100644 index 00000000000..e9b0b39d426 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/ColorPicker.tsx @@ -0,0 +1,108 @@ +import type { Color, ColorPickerValueChangeDetails } from '@chakra-ui/react'; +import type { ComponentProps } from 'react'; + +import { ColorPicker as ChakraColorPicker, parseColor, Portal } from '@chakra-ui/react'; +import { useCallback, useState } from 'react'; + +import { shouldSyncExternalColor } from './colorPickerSync'; + +export type ColorPickerSize = NonNullable['size']>; + +export interface ColorPickerProps { + /** The current color, as a `#rrggbb` (or any CSS-parseable) string. */ + value: string; + /** Called with the new `#rrggbb` hex string as the user drags the area/slider or edits the hex input. */ + onValueChange: (hex: string) => void; + /** + * Called with the final `#rrggbb` hex when an interaction ends (pointer up on + * the area/slider, or the hex input committing). Consumers that record undo + * history use this to collapse a whole drag into one entry, mirroring the + * `Slider` `onValueChangeEnd` pattern. + */ + onValueChangeEnd?: (hex: string) => void; + /** Accessible label for the swatch trigger button. */ + 'aria-label': string; + size?: ColorPickerSize; +} + +/** + * Workbench color picker: a compact swatch trigger that opens a popover with a + * saturation/hue area and a hex input. Wraps Chakra v3's composed + * `ColorPicker.*` parts (chrome comes from the built-in `colorPicker` slot + * recipe, which already reads workbench semantic tokens) — this is the single + * import point, kept to exactly what `BrushOptions` needs. + * + * Internally, the picker's controlled value is kept as a full Chakra/Zag + * `Color` (which preserves hue/saturation independent of RGB), not a hex + * string — see `shouldSyncExternalColor` for why. The external API stays + * hex-string based; hex is only produced at the `onValueChange` emit boundary. + */ +export const ColorPicker = ({ + 'aria-label': ariaLabel, + onValueChange, + onValueChangeEnd, + size = 'xs', + value, +}: ColorPickerProps) => { + const [previousExternalValue, setPreviousExternalValue] = useState(value); + const [color, setColor] = useState(() => parseColor(value)); + const [lastEmittedHex, setLastEmittedHex] = useState(() => color.toString('hex')); + + // Sync external -> internal only when the prop genuinely changed to + // something other than what we last emitted (see `shouldSyncExternalColor`). + if (value !== previousExternalValue) { + setPreviousExternalValue(value); + if (shouldSyncExternalColor(value, previousExternalValue, lastEmittedHex)) { + setColor(parseColor(value)); + } + } + + const handleValueChange = useCallback( + (details: ColorPickerValueChangeDetails) => { + setColor(details.value); + const hex = details.value.toString('hex'); + setLastEmittedHex(hex); + onValueChange(hex); + }, + [onValueChange] + ); + + const handleValueChangeEnd = useCallback( + (details: ColorPickerValueChangeDetails) => { + onValueChangeEnd?.(details.value.toString('hex')); + }, + [onValueChangeEnd] + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/ConfirmDialog.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/ConfirmDialog.tsx new file mode 100644 index 00000000000..cc6766b68aa --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/ConfirmDialog.tsx @@ -0,0 +1,91 @@ +/* eslint-disable react/react-compiler */ +import { Dialog, Portal, Stack, Text } from '@chakra-ui/react'; +import { useCallback, useState, type ReactNode } from 'react'; + +import { Button, CloseButton } from './Button'; + +/** + * Controlled confirmation dialog for consequential actions. The confirm + * button shows a pending state while `onConfirm` runs; errors are left to the + * caller (usually surfaced as a notification) and the dialog closes either way. + */ +export const ConfirmDialog = ({ + body, + confirmLabel, + isDestructive = true, + isOpen, + onClose, + onConfirm, + title, +}: { + body: ReactNode; + confirmLabel: string; + isDestructive?: boolean; + isOpen: boolean; + onClose: () => void; + onConfirm: () => Promise | void; + title: string; +}) => { + const [isPending, setIsPending] = useState(false); + + const handleConfirm = useCallback(async () => { + setIsPending(true); + + try { + await onConfirm(); + } finally { + setIsPending(false); + onClose(); + } + }, [onClose, onConfirm]); + + const handleOpenChange = useCallback( + (event: { open: boolean }) => { + if (!event.open) { + onClose(); + } + }, + [onClose] + ); + + const handleConfirmClick = useCallback(() => { + void handleConfirm(); + }, [handleConfirm]); + + return ( + + + + + + + + {title} + + + + {typeof body === 'string' ? {body} : body} + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/EmptyState.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/EmptyState.tsx new file mode 100644 index 00000000000..fb37633fbb4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/EmptyState.tsx @@ -0,0 +1,30 @@ +import { EmptyState as ChakraEmptyState, VStack } from '@chakra-ui/react'; +import * as React from 'react'; + +export interface EmptyStateProps extends ChakraEmptyState.RootProps { + title: string; + description?: string | null; + icon?: React.ReactNode; + danger?: boolean; +} + +export const EmptyState = React.forwardRef(function EmptyState(props, ref) { + const { title, description, icon, children, danger, ...rest } = props; + const fgColor = danger ? 'fg.error' : undefined; + return ( + + + {icon && {icon}} + {description ? ( + + {title} + {description} + + ) : ( + {title} + )} + {children} + + + ); +}); diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/Field.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/Field.tsx new file mode 100644 index 00000000000..32fa33e702d --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/Field.tsx @@ -0,0 +1,124 @@ +import type { ReactNode } from 'react'; + +import { Field as ChakraField, HStack, Stack, Text, useRecipe, type StackProps } from '@chakra-ui/react'; +import { fieldLabelRecipe } from '@theme/recipes'; +import { useMemo } from 'react'; + +/** + * The shared, theme-aware uppercase field label. Backed by `fieldLabelRecipe` so + * every form across the workbench renders an identical label without repeating + * the same five style props inline. + */ +export const FieldLabel = ({ children }: { children: ReactNode }) => { + const recipe = useRecipe({ recipe: fieldLabelRecipe }); + + return ( + + {children} + + ); +}; + +export interface FieldProps extends Omit { + id?: string; + label: string; + labelEnd?: ReactNode; + disabled?: boolean; + orientation?: 'horizontal' | 'vertical'; + invalid?: boolean; + readOnly?: boolean; + required?: boolean; + /** Validation error, shown in place of `helpText`; also marks the field invalid by default. */ + error?: string | null; + helpText?: string; + children: ReactNode; +} + +/** A labelled form field: an uppercase label stacked above its control, with an optional help/error line below. */ +export const Field = ({ + children, + disabled, + error, + helpText, + id, + invalid, + label, + labelEnd, + orientation = 'vertical', + readOnly, + required, + ...rest +}: FieldProps) => { + const recipe = useRecipe({ recipe: fieldLabelRecipe }); + const isHorizontal = orientation === 'horizontal'; + const isInvalid = invalid ?? Boolean(error); + const ids = useMemo( + () => + id + ? { + errorText: `${id}-error`, + helperText: `${id}-help`, + label: `${id}-label`, + } + : undefined, + [id] + ); + const labelContent = {label}; + const message = error ? ( + + {error} + + ) : helpText ? ( + + {helpText} + + ) : null; + + if (!isHorizontal) { + return ( + + + + {labelContent} + {labelEnd} + + {children} + {message} + + + ); + } + + return ( + + + + {labelContent} + {labelEnd} + + + {children} + {message} + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/JsonPreview.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/JsonPreview.tsx new file mode 100644 index 00000000000..5d4f34131b8 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/JsonPreview.tsx @@ -0,0 +1,112 @@ +import { Box, Code, Icon, ScrollArea } from '@chakra-ui/react'; +import { CheckIcon, CopyIcon } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { IconButton } from './Button'; +import { toaster } from './toaster'; + +/** + * The workbench's standard JSON preview: a monospace block with a copy button + * that owns its scrolling in both axes — long strings scroll horizontally + * instead of stretching the surrounding layout. Pass `value` to serialize, or + * `text` when the JSON string already exists (an export payload that must be + * copied byte-for-byte). Defaults to a bounded height; pass `maxH` (or wrap in + * a flex parent and pass `maxH="100%"`) to control it. + */ +export const JsonPreview = ({ + h, + label = 'JSON preview', + maxH = '24rem', + text, + value, +}: { + h?: string; + /** Accessible name for the scroll viewport. */ + label?: string; + maxH?: string; + text?: string; + value?: unknown; +}) => { + const [hasCopied, setHasCopied] = useState(false); + const copyResetTimerRef = useRef | null>(null); + const json = useMemo(() => text ?? JSON.stringify(value, null, 2) ?? 'null', [text, value]); + + useEffect( + () => () => { + if (copyResetTimerRef.current !== null) { + clearTimeout(copyResetTimerRef.current); + } + }, + [] + ); + + const copy = useCallback(() => { + navigator.clipboard + .writeText(json) + .then(() => { + setHasCopied(true); + + if (copyResetTimerRef.current !== null) { + clearTimeout(copyResetTimerRef.current); + } + + copyResetTimerRef.current = setTimeout(() => setHasCopied(false), 1500); + }) + .catch(() => toaster.create({ title: 'Failed to copy JSON', type: 'error' })); + }, [json]); + + return ( + + + + + + + + + {json} + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/Menu.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/Menu.tsx new file mode 100644 index 00000000000..4d295159567 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/Menu.tsx @@ -0,0 +1,13 @@ +import type { ComponentProps } from 'react'; + +import { Menu } from '@chakra-ui/react'; + +type MenuContentProps = ComponentProps; + +/** + * Menu.Content passthrough. The workbench popover chrome (surface, stroke, + * radius, shadow) is applied globally by the `menu` slot-recipe override in + * `theme/recipes.ts`; this wrapper only exists as the single import point + * for future menu-wide behavior. + */ +export const MenuContent = (props: MenuContentProps) => ; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/Panel.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/Panel.tsx new file mode 100644 index 00000000000..9f92013a96b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/Panel.tsx @@ -0,0 +1,19 @@ +import { Box, type BoxProps, type RecipeVariantProps, useRecipe } from '@chakra-ui/react'; +import { panelRecipe } from '@theme/recipes'; +import { useMemo } from 'react'; + +export type PanelProps = BoxProps & RecipeVariantProps; + +/** + * Bordered surface container backed by `panelRecipe` — the one styling + * contract for panels, cards, and wells across the workbench. + * + * - `tone`: `surface` (default) | `raised` | `inset` | `control` + * - `density`: `none` (default) | `sm` | `md` — padding + gap presets + */ +export const Panel = ({ css, density, tone, ...rest }: PanelProps) => { + const recipe = useRecipe({ recipe: panelRecipe }); + const panelCss = useMemo(() => [recipe({ density, tone }), css], [css, density, recipe, tone]); + + return ; +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/RenameDialog.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/RenameDialog.tsx new file mode 100644 index 00000000000..eaf0fc42ab3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/RenameDialog.tsx @@ -0,0 +1,112 @@ +import { chakra, Dialog, Input, Portal, Stack } from '@chakra-ui/react'; +import { useCallback, useState, type FormEvent } from 'react'; + +import { Button, CloseButton } from './Button'; +import { Field } from './Field'; + +/** + * Controlled single-field rename dialog. `onSubmit` only fires for a + * non-empty name that actually changed; it may be async, in which case the + * submit button shows a pending state and errors keep the dialog open so the + * caller's message (usually a toast) can be acted on. + */ +export const RenameDialog = ({ + initialName, + isOpen, + label = 'Project name', + onClose, + onSubmit, + submitLabel = 'Rename', + submitUnchanged = false, + title = 'Rename project', +}: { + initialName: string; + isOpen: boolean; + label?: string; + onClose: () => void; + onSubmit: (name: string) => Promise | void; + submitLabel?: string; + submitUnchanged?: boolean; + title?: string; +}) => { + const [isPending, setIsPending] = useState(false); + + const commit = useCallback( + async (value: string) => { + const name = value.trim(); + + if (!name || (!submitUnchanged && name === initialName.trim())) { + onClose(); + + return; + } + + setIsPending(true); + + try { + await onSubmit(name); + onClose(); + } catch { + // The caller surfaced the failure (toast/notification); stay open so + // the name is not lost. + } finally { + setIsPending(false); + } + }, + [initialName, onClose, onSubmit, submitUnchanged] + ); + + const handleOpenChange = useCallback( + (event: { open: boolean }) => { + if (!event.open) { + onClose(); + } + }, + [onClose] + ); + + const handleSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + void commit(new FormData(event.currentTarget).get('renameValue')?.toString() ?? ''); + }, + [commit] + ); + + return ( + + + + + + + + + {title} + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/ResizableTextarea.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/ResizableTextarea.tsx new file mode 100644 index 00000000000..9fede592834 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/ResizableTextarea.tsx @@ -0,0 +1,164 @@ +/* eslint-disable react/react-compiler */ +import type { + ComponentProps, + KeyboardEvent as ReactKeyboardEvent, + PointerEvent as ReactPointerEvent, + Ref, + ReactNode, +} from 'react'; + +import { Box, Textarea } from '@chakra-ui/react'; +import { useCallback, useState } from 'react'; + +type TextareaProps = ComponentProps; + +const DEFAULT_STEP_PX = 12; +const DEFAULT_LARGE_STEP_PX = 48; + +const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max); + +const resizeHandleAfter = { + bg: 'border.emphasized', + borderRadius: 'full', + bottom: '1px', + content: '""', + h: '1px', + left: '25%', + opacity: 0.55, + position: 'absolute', + right: '25%', +} as const; + +const resizeHandleFocusVisible = { bg: 'accent.solid/20', outline: '2px solid {colors.accent.solid}' } as const; +const resizeHandleHover = { bg: 'accent.solid/12' } as const; + +export interface ResizableTextareaProps extends Omit< + TextareaProps, + 'h' | 'height' | 'maxH' | 'maxHeight' | 'minH' | 'minHeight' | 'resize' +> { + defaultHeightPx: number; + maxHeightPx?: number; + minHeightPx: number; + resizeHandleAriaLabel: string; + largeStepPx?: number; + stepPx?: number; + textareaRef?: Ref; + underlay?: ReactNode; + onResizeEnd?: (heightPx: number) => void; +} + +export const ResizableTextarea = ({ + defaultHeightPx, + largeStepPx = DEFAULT_LARGE_STEP_PX, + maxHeightPx = 420, + minHeightPx, + onResizeEnd, + resizeHandleAriaLabel, + stepPx = DEFAULT_STEP_PX, + textareaRef, + underlay, + ...textareaProps +}: ResizableTextareaProps) => { + const initialHeightPx = clamp(defaultHeightPx, minHeightPx, maxHeightPx); + const [heightPx, setHeightPx] = useState(initialHeightPx); + const [dragHeightPx, setDragHeightPx] = useState(null); + const displayHeightPx = dragHeightPx ?? heightPx; + + const commitHeight = useCallback( + (nextHeightPx: number) => { + const clampedHeightPx = clamp(nextHeightPx, minHeightPx, maxHeightPx); + + setHeightPx(clampedHeightPx); + onResizeEnd?.(clampedHeightPx); + }, + [maxHeightPx, minHeightPx, onResizeEnd] + ); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault(); + + const startY = event.clientY; + const startHeightPx = displayHeightPx; + let nextHeightPx = startHeightPx; + + const handlePointerMove = (moveEvent: PointerEvent) => { + nextHeightPx = clamp(startHeightPx + moveEvent.clientY - startY, minHeightPx, maxHeightPx); + setDragHeightPx(nextHeightPx); + }; + + const handlePointerUp = () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); + setDragHeightPx(null); + commitHeight(nextHeightPx); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointercancel', handlePointerUp); + }, + [commitHeight, displayHeightPx, maxHeightPx, minHeightPx] + ); + + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + const step = event.shiftKey ? largeStepPx : stepPx; + const heightChange = + event.key === 'ArrowDown' + ? step + : event.key === 'ArrowUp' + ? -step + : event.key === 'End' + ? maxHeightPx - displayHeightPx + : event.key === 'Home' + ? minHeightPx - displayHeightPx + : undefined; + + if (heightChange === undefined) { + return; + } + + event.preventDefault(); + commitHeight(displayHeightPx + heightChange); + }, + [commitHeight, displayHeightPx, largeStepPx, maxHeightPx, minHeightPx, stepPx] + ); + + return ( + + {underlay} +