diff --git a/docs/src/content/docs/development/Front End/text-tool.mdx b/docs/src/content/docs/development/Front End/text-tool.mdx index 5b19bbeef9f..ef4bf2b89b8 100644 --- a/docs/src/content/docs/development/Front End/text-tool.mdx +++ b/docs/src/content/docs/development/Front End/text-tool.mdx @@ -1,5 +1,5 @@ --- -title: "Canvas Text Tool" +title: 'Canvas Text Tool' --- ## Overview @@ -29,6 +29,13 @@ Layer placement preserves the original click location: Font definitions live in `invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts` as ten deterministic stacks (sans, serif, mono, rounded, script, humanist, slab serif, display, narrow, UI serif). Each stack lists system-safe fallbacks so the editor can choose the first available font per platform. +The Text tool also supports server-managed custom fonts from the configured `fonts_dir` directory, which defaults to `fonts` in the InvokeAI root: + +- `invokeai/app/services/config/config_default.py` ensures the configured directory and its `README.txt` exist. +- `invokeai/app/api/routers/utilities.py` lists supported font files (`.ttf`, `.otf`, `.woff`, `.woff2`) and serves them to the frontend. +- `invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx` groups custom fonts separately from built-in stacks and keeps them disabled until their `FontFace` entries are ready. +- `invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts` waits for custom font readiness before rasterizing text, avoiding fallback renders when a font has been listed but not fully loaded yet. + To add or adjust fonts: 1. Update `TEXT_FONT_STACKS` with the new `id`, `label`, and CSS `font-family` stack. diff --git a/docs/src/content/docs/features/Canvas/text-tool.mdx b/docs/src/content/docs/features/Canvas/text-tool.mdx index 19308b0f5a1..c8266101397 100644 --- a/docs/src/content/docs/features/Canvas/text-tool.mdx +++ b/docs/src/content/docs/features/Canvas/text-tool.mdx @@ -10,6 +10,17 @@ import { LinkCard } from '@astrojs/starlight/components'; The Text tool uses a set of predefined font stacks. When you choose a font, the app resolves the first available font on your system from that stack and uses it for both the editor overlay and the rasterized result. This provides consistent styling across platforms while still falling back to safe system fonts if a preferred font is missing. +## Custom fonts + +InvokeAI can also load custom fonts from the host-managed `fonts_dir` directory. It defaults to `fonts` in the InvokeAI root folder and can be changed in `invokeai.yaml`. + +- On startup, InvokeAI creates `README.txt` in the configured directory if it does not already exist. +- Place `.ttf`, `.otf`, `.woff`, or `.woff2` files in the configured directory or any subfolder under it. +- Refresh the app after adding or removing fonts so the Text tool can reload the available font list. +- If the configured directory is empty, the Text tool continues to use the built-in font list. + +In multiuser deployments, this directory is shared by the entire InvokeAI instance. Fonts added there are available to all authenticated users of that server. + ## Size and spacing - **Size** controls the font size in pixels. diff --git a/docs/src/generated/settings.json b/docs/src/generated/settings.json index af4c6a0e746..66a0728fb14 100644 --- a/docs/src/generated/settings.json +++ b/docs/src/generated/settings.json @@ -202,6 +202,17 @@ "type": "", "validation": {} }, + { + "category": "PATHS", + "default": "fonts", + "description": "Path to directory for custom fonts.", + "env_var": "INVOKEAI_FONTS_DIR", + "literal_values": [], + "name": "fonts_dir", + "required": false, + "type": "", + "validation": {} + }, { "category": "PATHS", "default": "flat", diff --git a/invokeai/app/api/routers/utilities.py b/invokeai/app/api/routers/utilities.py index 0ede5441c46..d483bfc95e8 100644 --- a/invokeai/app/api/routers/utilities.py +++ b/invokeai/app/api/routers/utilities.py @@ -1,13 +1,18 @@ import asyncio import logging +import re import threading from pathlib import Path from typing import Optional, Union +from urllib.parse import quote import torch from dynamicprompts.generators import CombinatorialPromptGenerator, RandomPromptGenerator from fastapi import Body, HTTPException +from fastapi.responses import FileResponse from fastapi.routing import APIRouter +from fontTools.ttLib import TTFont +from PIL import ImageFont from pydantic import BaseModel, Field from pyparsing import ParseException from transformers import AutoProcessor, AutoTokenizer, LlavaOnevisionForConditionalGeneration, LlavaOnevisionProcessor @@ -29,6 +34,14 @@ logger = logging.getLogger(__name__) utilities_router = APIRouter(prefix="/v1/utilities", tags=["utilities"]) +SUPPORTED_FONT_EXTENSIONS = {".ttf", ".otf", ".woff", ".woff2"} +FONT_MEDIA_TYPES = { + ".ttf": "font/ttf", + ".otf": "font/otf", + ".woff": "font/woff", + ".woff2": "font/woff2", +} +FONT_CACHE_CONTROL = "private, max-age=31536000, immutable" # The underlying model loader is not thread-safe, so we serialize load_model calls. _model_load_lock = threading.Lock() @@ -39,6 +52,26 @@ class DynamicPromptsResponse(BaseModel): error: Optional[str] = None +class UserFontFace(BaseModel): + path: str + url: str + weight: int + style: str + + +class UserFont(BaseModel): + id: str + family: str + label: str + path: str + url: str + faces: list[UserFontFace] + + +class UserFontsResponse(BaseModel): + fonts: list[UserFont] + + @utilities_router.post( "/dynamicprompts", operation_id="parse_dynamicprompts", @@ -81,6 +114,257 @@ async def parse_dynamicprompts( return DynamicPromptsResponse(prompts=prompts if prompts else [""], error=error) +def _get_fonts_dir() -> Path: + return ApiDependencies.invoker.services.configuration.fonts_path + + +def _path_has_symlink_component(path: Path, boundary: Path) -> bool: + current = path + boundary = boundary.absolute() + try: + current.absolute().relative_to(boundary) + except ValueError: + return True + + while True: + if current.is_symlink(): + return True + if current == boundary: + return False + current = current.parent + + +def _get_name_table_value(font: TTFont, name_ids: tuple[int, ...]) -> str | None: + if "name" not in font: + return None + + def _sort_key(record: object) -> tuple[int, int]: + platform_id = getattr(record, "platformID", -1) + lang_id = getattr(record, "langID", -1) + if platform_id == 3 and lang_id in (0x0409, 0): + return (0, 0) + if platform_id == 3: + return (1, 0) + if platform_id == 0: + return (2, 0) + return (3, lang_id) + + records = font["name"].names + for name_id in name_ids: + for record in sorted((record for record in records if record.nameID == name_id), key=_sort_key): + try: + value = record.toUnicode().strip() + except Exception: + continue + if value: + return value + return None + + +def _normalize_variant_text(value: str) -> str: + value = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", value) + value = value.replace("_", " ").replace("-", " ") + value = re.sub(r"\s+", " ", value) + return value.strip().lower() + + +def _infer_font_weight(style_name: str, file_stem: str, weight_class: int | None) -> int: + if isinstance(weight_class, int) and 1 <= weight_class <= 1000: + return weight_class + + combined = _normalize_variant_text(f"{style_name} {file_stem}") + # Ordering matters: more specific keywords must be matched before "bold". + weight_keywords = [ + (("thin", "hairline"), 100), + (("extra light", "ultra light", "extralight", "ultralight"), 200), + (("light",), 300), + (("normal", "regular", "roman", "book"), 400), + (("medium",), 500), + (("semi bold", "semibold", "demi bold", "demibold"), 600), + (("extra bold", "ultra bold", "extrabold", "ultrabold"), 800), + (("black", "heavy"), 900), + (("bold",), 700), + ] + for keywords, weight in weight_keywords: + if any(keyword in combined for keyword in keywords): + return weight + + return 400 + + +def _infer_font_style(style_name: str, file_stem: str, italic_flag: bool) -> str: + if italic_flag: + return "italic" + + combined = _normalize_variant_text(f"{style_name} {file_stem}") + if "italic" in combined or "oblique" in combined: + return "italic" + + return "normal" + + +def _get_font_metadata(font_file: Path) -> tuple[str, str, int, str] | None: + ttfont_error: Exception | None = None + try: + with TTFont(font_file.as_posix(), lazy=True) as font: + family_name = (_get_name_table_value(font, (16, 1)) or "").strip() + style_name = (_get_name_table_value(font, (17, 2)) or "").strip() + os2_table = font["OS/2"] if "OS/2" in font else None + head_table = font["head"] if "head" in font else None + post_table = font["post"] if "post" in font else None + weight_class = getattr(os2_table, "usWeightClass", None) + italic_flag = bool(getattr(os2_table, "fsSelection", 0) & 0x01) or bool( + getattr(head_table, "macStyle", 0) & 0x02 + ) + if post_table is not None: + italic_flag = italic_flag or bool(getattr(post_table, "italicAngle", 0)) + if family_name: + return ( + family_name, + family_name, + _infer_font_weight(style_name, font_file.stem, weight_class), + _infer_font_style(style_name, font_file.stem, italic_flag), + ) + except Exception as e: + ttfont_error = e + + try: + font = ImageFont.truetype(font_file.as_posix(), size=16) + family_name, style_name = font.getname() + family_name = family_name.strip() + style_name = style_name.strip() + if family_name: + return ( + family_name, + family_name, + _infer_font_weight(style_name, font_file.stem, None), + _infer_font_style(style_name, font_file.stem, False), + ) + except Exception as e: + logger.warning( + "Skipping font file %s: unable to read font metadata with fontTools or Pillow (%s, %s)", + font_file, + type(ttfont_error).__name__ if ttfont_error else "no-fontTools-error", + type(e).__name__, + exc_info=e, + ) + return None + + logger.warning("Skipping font file %s: missing font family metadata", font_file) + return None + + +def _resolve_font_request_path(font_path: str) -> Path: + fonts_dir = _get_fonts_dir() + if not fonts_dir.exists() or not fonts_dir.is_dir(): + raise HTTPException(status_code=404, detail="Font file not found") + + requested = (fonts_dir / font_path).absolute() + if _path_has_symlink_component(requested, fonts_dir): + raise HTTPException(status_code=400, detail="Invalid font path") + + resolved_fonts_dir = fonts_dir.resolve() + resolved_requested = requested.resolve() + + try: + resolved_requested.relative_to(resolved_fonts_dir) + except ValueError as e: + raise HTTPException(status_code=400, detail="Invalid font path") from e + + return resolved_requested + + +@utilities_router.get( + "/fonts", + operation_id="list_user_fonts", + responses={200: {"model": UserFontsResponse}}, +) +def list_user_fonts(_current_user: CurrentUserOrDefault) -> UserFontsResponse: + fonts_dir = _get_fonts_dir() + if not fonts_dir.exists() or not fonts_dir.is_dir() or fonts_dir.is_symlink(): + if fonts_dir.is_symlink(): + logger.warning("Skipping custom fonts directory %s: symlinks are not supported", fonts_dir) + return UserFontsResponse(fonts=[]) + + family_candidates: dict[str, list[tuple[Path, str, str, int, str]]] = {} + # key -> [(font_file, relative, family, weight, style)] + for font_file in sorted(fonts_dir.rglob("*")): + if _path_has_symlink_component(font_file.absolute(), fonts_dir): + logger.warning("Skipping font path %s: symlinks are not supported", font_file) + continue + if not font_file.is_file() or font_file.suffix.lower() not in SUPPORTED_FONT_EXTENSIONS: + continue + relative = font_file.relative_to(fonts_dir).as_posix() + metadata = _get_font_metadata(font_file) + if metadata is None: + continue + family, _label, weight, style = metadata + family_key = family.strip().lower() + family_candidates.setdefault(family_key, []).append((font_file, relative, family, weight, style)) + + def _candidate_score(weight: int, style: str, path: Path) -> tuple[int, int, int]: + """Lower score is better. Prefer regular/normal faces, then shorter names.""" + return (0 if style == "normal" else 1, abs(weight - 400), len(path.stem)) + + fonts: list[UserFont] = [] + for _, candidates in sorted(family_candidates.items(), key=lambda kv: kv[0]): + _, selected_relative, selected_family, _, _ = min(candidates, key=lambda c: _candidate_score(c[3], c[4], c[0])) + faces_by_variant: dict[tuple[int, str], tuple[Path, str, str, int, str]] = {} + for candidate in candidates: + variant_key = (candidate[3], candidate[4]) + current = faces_by_variant.get(variant_key) + if current is None or _candidate_score(candidate[3], candidate[4], candidate[0]) < _candidate_score( + current[3], current[4], current[0] + ): + faces_by_variant[variant_key] = candidate + + faces = [ + UserFontFace( + path=relative, + url=f"api/v1/utilities/fonts/{quote(relative)}", + weight=weight, + style=style, + ) + for (weight, style), (_, relative, _, _, _) in sorted( + faces_by_variant.items(), key=lambda item: (item[0][1] != "normal", abs(item[0][0] - 400), item[0][0]) + ) + ] + + fonts.append( + UserFont( + id=f"user:{selected_relative}", + family=selected_family, + label=selected_family, + path=selected_relative, + url=f"api/v1/utilities/fonts/{quote(selected_relative)}", + faces=faces, + ) + ) + + return UserFontsResponse(fonts=fonts) + + +@utilities_router.get( + "/fonts/{font_path:path}", + operation_id="get_user_font_file", +) +def get_user_font_file(font_path: str, _current_user: CurrentUserOrDefault) -> FileResponse: + requested = _resolve_font_request_path(font_path) + if not requested.exists() or not requested.is_file(): + raise HTTPException(status_code=404, detail="Font file not found") + + if requested.suffix.lower() not in SUPPORTED_FONT_EXTENSIONS: + raise HTTPException(status_code=400, detail="Unsupported font format") + + return FileResponse( + path=requested, + media_type=FONT_MEDIA_TYPES[requested.suffix.lower()], + filename=requested.name, + content_disposition_type="inline", + headers={"Cache-Control": FONT_CACHE_CONTROL}, + ) + + # --- Expand Prompt --- @@ -257,7 +541,7 @@ def _run_image_to_prompt( with _model_load_lock: loaded_model = model_manager.load.load_model(model_config, user_id=user_id) - # Load the image from InvokeAI's image store + # Load the image from InvokeAI's image store. image = ApiDependencies.invoker.services.images.get_pil_image(image_name) image = image.convert("RGB") diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index 873ce69b664..25180ecec16 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -6,6 +6,7 @@ import copy import filecmp import locale +import logging import os import re import shutil @@ -47,6 +48,8 @@ "external_seedream_base_url", ) +logger = logging.getLogger(__name__) + class URLRegexTokenPair(BaseModel): url_regex: str = Field(description="Regular expression to match against the URL") @@ -179,6 +182,7 @@ class InvokeAIAppConfig(BaseSettings): legacy_conf_dir: Path = Field(default=Path("configs"), description="Path to directory of legacy checkpoint config files.") db_dir: Path = Field(default=Path("databases"), description="Path to InvokeAI databases directory.") outputs_dir: Path = Field(default=Path("outputs"), description="Path to directory for outputs.") + fonts_dir: Path = Field(default=Path("fonts"), description="Path to directory for custom fonts.") image_subfolder_strategy: IMAGE_SUBFOLDER_STRATEGY = Field(default="flat", description="Strategy for organizing images into subfolders. 'flat' stores all images in a single folder. 'date' organizes by YYYY/MM/DD. 'type' organizes by image category. 'hash' uses first 2 characters of UUID for filesystem performance.") custom_nodes_dir: Path = Field(default=Path("nodes"), description="Path to directory for custom nodes.") style_presets_dir: Path = Field(default=Path("style_presets"), description="Path to directory for style presets.") @@ -407,6 +411,11 @@ def outputs_path(self) -> Optional[Path]: """Path to the outputs directory, resolved to an absolute path..""" return self._resolve(self.outputs_dir) + @property + def fonts_path(self) -> Path: + """Path to the custom fonts directory, resolved to an absolute path.""" + return self._resolve(self.fonts_dir) + @property def db_path(self) -> Path: """Path to the invokeai.db file, resolved to an absolute path..""" @@ -661,6 +670,22 @@ def load_external_api_keys(api_keys_file_path: Path) -> dict[str, str]: return parsed_api_keys +def ensure_fonts_dir(fonts_path: Path) -> None: + fonts_readme_path = fonts_path / "README.txt" + + try: + fonts_path.mkdir(parents=True, exist_ok=True) + if not fonts_readme_path.exists(): + with open(fonts_readme_path, "wt", encoding="utf-8") as f: + f.write( + "Custom fonts folder for InvokeAI text tools.\n\n" + "Place your font files in this folder (or subfolders).\n" + "Supported formats: .ttf, .otf, .woff, .woff2\n" + ) + except OSError: + logger.warning("Unable to initialize fonts directory at %s", fonts_path, exc_info=True) + + @lru_cache(maxsize=1) def get_config() -> InvokeAIAppConfig: """Get the global singleton app config. @@ -739,6 +764,8 @@ def get_config() -> InvokeAIAppConfig: default_config = DefaultInvokeAIAppConfig() default_config.write_file(config.config_file_path, as_example=False) + ensure_fonts_dir(config.fonts_path) + api_keys_from_file = load_external_api_keys(config.api_keys_file_path) if api_keys_from_file: # API keys file should take precedence over invokeai.yaml, but not over environment variables. diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 48cfb634896..592376f4cfb 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -514,6 +514,73 @@ ] } }, + "/api/v1/utilities/fonts": { + "get": { + "tags": ["utilities"], + "summary": "List User Fonts", + "operationId": "list_user_fonts", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserFontsResponse" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/utilities/fonts/{font_path}": { + "get": { + "tags": ["utilities"], + "summary": "Get User Font File", + "operationId": "get_user_font_file", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "font_path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Font Path" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/utilities/expand-prompt": { "post": { "tags": ["utilities"], @@ -48642,6 +48709,13 @@ "description": "Path to directory for outputs.", "default": "outputs" }, + "fonts_dir": { + "type": "string", + "format": "path", + "title": "Fonts Dir", + "description": "Path to directory for custom fonts.", + "default": "fonts" + }, "image_subfolder_strategy": { "type": "string", "enum": ["flat", "date", "type", "hash"], @@ -85315,6 +85389,77 @@ "title": "UserDTO", "description": "User data transfer object." }, + "UserFont": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "family": { + "type": "string", + "title": "Family" + }, + "label": { + "type": "string", + "title": "Label" + }, + "path": { + "type": "string", + "title": "Path" + }, + "url": { + "type": "string", + "title": "Url" + }, + "faces": { + "items": { + "$ref": "#/components/schemas/UserFontFace" + }, + "type": "array", + "title": "Faces" + } + }, + "type": "object", + "required": ["id", "family", "label", "path", "url", "faces"], + "title": "UserFont" + }, + "UserFontFace": { + "properties": { + "path": { + "type": "string", + "title": "Path" + }, + "url": { + "type": "string", + "title": "Url" + }, + "weight": { + "type": "integer", + "title": "Weight" + }, + "style": { + "type": "string", + "title": "Style" + } + }, + "type": "object", + "required": ["path", "url", "weight", "style"], + "title": "UserFontFace" + }, + "UserFontsResponse": { + "properties": { + "fonts": { + "items": { + "$ref": "#/components/schemas/UserFont" + }, + "type": "array", + "title": "Fonts" + } + }, + "type": "object", + "required": ["fonts"], + "title": "UserFontsResponse" + }, "UserProfileUpdateRequest": { "properties": { "display_name": { diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 3ecdb66818c..f87aae7783d 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2049,6 +2049,11 @@ "imageSavingFailed": "Image Saving Failed", "imageUploaded": "Image Uploaded", "imageUploadFailed": "Image Upload Failed", + "customFontLoadFailed": "Custom Font Failed to Load", + "customFontLoadFailedDesc": "Could not load custom font: {{fontName}}.", + "customFontStillLoading": "Custom Font Still Loading", + "customFontStillLoadingDesc": "Text was not committed because the selected custom font is still loading. Try again in a moment.", + "customFontUnavailableDesc": "Text was not committed because the selected custom font could not be loaded. Choose another font or check the font file.", "imageStorageMaintenanceActive": "Image storage maintenance is active. Recover it before retrying the upload.", "videoUploaded": "Video Uploaded", "videoUploadFailed": "Video Upload Failed", @@ -3019,6 +3024,10 @@ "text": { "font": "Font", "size": "Size", + "lineHeight": "Spacing", + "lineHeightDense": "Dense", + "lineHeightNormal": "Normal", + "lineHeightSpacious": "Spacious", "bold": "Bold", "italic": "Italic", "underline": "Underline", @@ -3026,11 +3035,11 @@ "alignLeft": "Align Left", "alignCenter": "Align Center", "alignRight": "Align Right", - "px": "px", - "lineHeight": "Spacing", - "lineHeightDense": "Dense", - "lineHeightNormal": "Normal", - "lineHeightSpacious": "Spacious" + "customFonts": "User Fonts", + "builtInFonts": "Built-in Fonts", + "missingFont": "Missing Font", + "fontLoadFailed": "failed to load", + "px": "px" }, "newCanvasFromImage": "New Canvas from Image", "newImg2ImgCanvasFromImage": "New Img2Img from Image", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx index 077141bdc75..37a6e4e5b80 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx @@ -7,7 +7,11 @@ import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSe import type { CanvasTextSettingsState } from 'features/controlLayers/store/canvasTextSlice'; import { selectCanvasTextSlice } from 'features/controlLayers/store/canvasTextSlice'; import type { Coordinate } from 'features/controlLayers/store/types'; -import { getFontStackById, TEXT_RASTER_PADDING } from 'features/controlLayers/text/textConstants'; +import { + $customTextFontStacks, + getFontStackById, + TEXT_RASTER_PADDING, +} from 'features/controlLayers/text/textConstants'; import { isAllowedTextShortcut } from 'features/controlLayers/text/textHotkeys'; import { measureTextContent, type TextMeasureConfig } from 'features/controlLayers/text/textRenderer'; import { @@ -28,6 +32,7 @@ export const CanvasTextOverlay = memo(() => { const canvasManager = useCanvasManager(); const session = useStore(canvasManager.tool.tools.text.$session); const stageAttrs = useStore(canvasManager.stage.$stageAttrs); + useStore($customTextFontStacks); if (!session) { return null; @@ -71,12 +76,16 @@ const ROTATE_ANCHOR_LINE_LENGTH = ROTATE_ANCHOR_GAP; const ROTATE_ANCHOR_FILL = 'invokeBlue.50'; const ROTATE_ANCHOR_STROKE = 'invokeBlue.500'; -const buildMeasureConfig = (text: string, settings: CanvasTextSettingsState): TextMeasureConfig => { +const buildMeasureConfig = ( + text: string, + settings: CanvasTextSettingsState, + fontFamily: TextMeasureConfig['fontFamily'] +): TextMeasureConfig => { const fontStyle: TextMeasureConfig['fontStyle'] = settings.italic ? 'italic' : 'normal'; return { text, fontSize: settings.fontSize, - fontFamily: getFontStackById(settings.fontId), + fontFamily, fontWeight: settings.bold ? 700 : 400, fontStyle, lineHeight: settings.lineHeight, @@ -99,6 +108,7 @@ const TextEditor = ({ const canvasManager = useCanvasManager(); const textSettings = useAppSelector(selectCanvasTextSlice); const canvasSettings = useAppSelector(selectCanvasSettingsSlice); + useStore($customTextFontStacks); const containerRef = useRef(null); const editorRef = useRef(null); const lastSessionIdRef = useRef(null); @@ -111,9 +121,10 @@ const TextEditor = ({ const [isDragging, setIsDragging] = useState(false); const [isRotating, setIsRotating] = useState(false); const [textValue, setTextValue] = useState(initialText); + const fontFamily = getFontStackById(textSettings.fontId); const contentMetrics = useMemo( - () => measureTextContent(buildMeasureConfig(textValue, textSettings)), - [textValue, textSettings] + () => measureTextContent(buildMeasureConfig(textValue, textSettings, fontFamily)), + [fontFamily, textSettings, textValue] ); const textContainerData = useMemo(() => { const padding = TEXT_RASTER_PADDING; @@ -570,7 +581,7 @@ const TextEditor = ({ decorations.push('line-through'); } return { - fontFamily: getFontStackById(textSettings.fontId), + fontFamily, fontWeight: textSettings.bold ? 700 : 400, fontStyle: textSettings.italic ? 'italic' : 'normal', textDecorationLine: decorations.length ? decorations.join(' ') : 'none', @@ -579,7 +590,7 @@ const TextEditor = ({ color, textAlign: textSettings.alignment, } as const; - }, [canvasSettings, contentMetrics.lineHeightPx, textSettings]); + }, [canvasSettings, contentMetrics.lineHeightPx, fontFamily, textSettings]); const stageScale = stageAttrs.scale || 1; const outlineScale = stageScale ? 1 / stageScale : 1; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx index ab7b164514d..5922ceb5fa8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx @@ -2,6 +2,7 @@ import { Box, ButtonGroup, Combobox, + type ComboboxOption, CompositeSlider, Flex, IconButton, @@ -17,7 +18,10 @@ import { Text, Tooltip, } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import type { GroupBase } from 'chakra-react-select'; +import { selectAuthToken } from 'features/auth/store/authSlice'; import { selectTextAlignment, selectTextFontId, @@ -33,14 +37,25 @@ import { textUnderlineToggled, } from 'features/controlLayers/store/canvasTextSlice'; import { + getTextFontStack, + isCustomTextFontId, resolveAvailableFont, + setCustomTextFontStacks, TEXT_FONT_STACKS, TEXT_MAX_FONT_SIZE, TEXT_MIN_FONT_SIZE, type TextFontId, } from 'features/controlLayers/text/textConstants'; +import { + $userFontReadyStates, + buildCustomTextFontStacks, + loadedUserFontFaces, + primeUserFontReadiness, + syncUserFontFaces, +} from 'features/controlLayers/text/textUserFonts'; +import { toast } from 'features/toast/toast'; import type { FocusEvent, KeyboardEvent, MouseEvent } from 'react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCaretDownBold, @@ -52,8 +67,18 @@ import { PiTextStrikethroughBold, PiTextUnderlineBold, } from 'react-icons/pi'; +import { getBaseUrl } from 'services/api'; +import { useListUserFontsQuery } from 'services/api/endpoints/utilities'; const formatSliderValue = (value: number) => String(value); +const toastedUserFontLoadErrorIds = new Set(); + +const truncateLabel = (value: string, maxLength: number = 36): string => { + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, maxLength - 3)}...`; +}; export const TextToolOptions = () => { return ( @@ -71,16 +96,172 @@ const FontSelect = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const fontId = useAppSelector(selectTextFontId); + const authToken = useAppSelector(selectAuthToken); + const { data: userFonts } = useListUserFontsQuery(); + const autoRetryAttemptsRef = useRef(0); + const userFontReadyStates = useStore($userFontReadyStates); + const [fontSyncVersion, setFontSyncVersion] = useState(0); + const userFontsLabel = t('controlLayers.text.customFonts'); + const builtInFontsLabel = t('controlLayers.text.builtInFonts'); + const missingFontLabel = t('controlLayers.text.missingFont'); + const fontLoadFailedLabel = t('controlLayers.text.fontLoadFailed'); + const customFontStacks = useMemo(() => buildCustomTextFontStacks(userFonts ?? []), [userFonts]); + const hasUserFontErrors = useMemo(() => { + return (userFonts ?? []).some((font) => userFontReadyStates[font.id] === 'error'); + }, [userFontReadyStates, userFonts]); + + useEffect(() => { + setCustomTextFontStacks(customFontStacks); + }, [customFontStacks]); + + useEffect(() => { + if (!isCustomTextFontId(fontId) || !userFonts || userFonts.length === 0) { + return; + } + const hasExactUserFont = userFonts.some((font) => font.id === fontId); + if (hasExactUserFont) { + return; + } + const resolvedFont = getTextFontStack(fontId); + if (resolvedFont && resolvedFont.id !== fontId) { + dispatch(textFontChanged(resolvedFont.id)); + } + }, [dispatch, fontId, userFonts]); + + useEffect(() => { + if (typeof document === 'undefined' || typeof FontFace === 'undefined') { + return; + } + + primeUserFontReadiness(userFonts ?? [], loadedUserFontFaces); + let isCancelled = false; + + void (async () => { + await syncUserFontFaces({ + fonts: userFonts ?? [], + token: authToken, + baseUrl: getBaseUrl(), + loadedFontFaces: loadedUserFontFaces, + fontFaceSet: document.fonts, + fontFaceCtor: FontFace, + fetchFn: fetch, + }); + + if (!isCancelled) { + // Trigger downstream re-measurement once font availability has changed. + setCustomTextFontStacks([...customFontStacks]); + } + })(); + + return () => { + isCancelled = true; + }; + }, [authToken, customFontStacks, fontSyncVersion, userFonts]); + + useEffect(() => { + if (!hasUserFontErrors) { + autoRetryAttemptsRef.current = 0; + return; + } + if (typeof window === 'undefined' || autoRetryAttemptsRef.current >= 2) { + return; + } + + const delayMs = autoRetryAttemptsRef.current === 0 ? 1000 : 3000; + const timeout = window.setTimeout(() => { + autoRetryAttemptsRef.current += 1; + setFontSyncVersion((version) => version + 1); + }, delayMs); + + return () => { + window.clearTimeout(timeout); + }; + }, [fontSyncVersion, hasUserFontErrors]); + + useEffect(() => { + if (!hasUserFontErrors || typeof window === 'undefined') { + return; + } + + const retry = () => { + setFontSyncVersion((version) => version + 1); + }; + + window.addEventListener('focus', retry); + window.addEventListener('online', retry); + + return () => { + window.removeEventListener('focus', retry); + window.removeEventListener('online', retry); + }; + }, [hasUserFontErrors]); + useEffect(() => { + for (const font of userFonts ?? []) { + if (userFontReadyStates[font.id] !== 'error') { + toastedUserFontLoadErrorIds.delete(font.id); + continue; + } + if (toastedUserFontLoadErrorIds.has(font.id)) { + continue; + } + toastedUserFontLoadErrorIds.add(font.id); + toast({ + id: `custom-font-load-failed:${font.id}`, + status: 'error', + title: t('toast.customFontLoadFailed'), + description: t('toast.customFontLoadFailedDesc', { fontName: font.label }), + withCount: false, + }); + } + }, [t, userFontReadyStates, userFonts]); + const options = useMemo(() => { - return TEXT_FONT_STACKS.map(({ id, label, stack }) => { + const customOptions: ComboboxOption[] = (userFonts ?? []).map((font) => { + return { + value: font.id, + label: truncateLabel( + `${font.label}${userFontReadyStates[font.id] === 'error' ? ` (${fontLoadFailedLabel})` : ''}` + ), + isDisabled: userFontReadyStates[font.id] !== 'ready', + }; + }); + const builtInOptions: ComboboxOption[] = TEXT_FONT_STACKS.map(({ id, label, stack }) => { const resolved = resolveAvailableFont(stack); + const display = truncateLabel(`${label} (${resolved})`); return { value: id, - label: `${label} (${resolved})`, + label: display, }; }); - }, []); - const selectedOption = options.find((option) => option.value === fontId) ?? null; + if (customOptions.length === 0) { + return builtInOptions; + } + return [ + { + label: userFontsLabel, + options: customOptions, + }, + { label: builtInFontsLabel, options: builtInOptions }, + ] as GroupBase[]; + }, [builtInFontsLabel, fontLoadFailedLabel, userFontReadyStates, userFonts, userFontsLabel]); + const selectedOption = useMemo(() => { + const firstOption = options[0]; + const flattened = + firstOption && 'options' in firstOption + ? (options as GroupBase[]).flatMap((group) => group.options) + : (options as ComboboxOption[]); + const existingOption = flattened.find((option) => option.value === fontId) ?? null; + if (existingOption) { + return existingOption; + } + if (isCustomTextFontId(fontId) && !getTextFontStack(fontId)) { + return { + value: fontId, + label: `${missingFontLabel} (${truncateLabel(fontId.slice(5))})`, + }; + } + return null; + }, [fontId, missingFontLabel, options]); const handleFontChange = useCallback( (option: { value: string } | null) => { if (!option) { @@ -90,9 +271,23 @@ const FontSelect = () => { }, [dispatch] ); + const formatFontGroupLabel = useCallback( + (group: GroupBase) => { + const isBuiltInGroup = group.label === builtInFontsLabel; + return ( + + {isBuiltInGroup && } + + {group.label} + + + ); + }, + [builtInFontsLabel] + ); return ( - + {t('controlLayers.text.font')} @@ -103,6 +298,7 @@ const FontSelect = () => { options={options} value={selectedOption} onChange={handleFontChange} + formatGroupLabel={formatFontGroupLabel} /> ); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts index 15ca85fbea1..fce673ac2ff 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts @@ -5,7 +5,11 @@ import { canvasToBlob, getPrefixedId } from 'features/controlLayers/konva/util'; import { type CanvasTextSettingsState, selectCanvasTextSlice } from 'features/controlLayers/store/canvasTextSlice'; import type { Coordinate, RgbaColor } from 'features/controlLayers/store/types'; import { buildCommittedTextImageState, getCommittedTextImageDimensions } from 'features/controlLayers/text/textCommit'; -import { getFontStackById, TEXT_RASTER_PADDING } from 'features/controlLayers/text/textConstants'; +import { + getFontStackById, + subscribeToCustomTextFontStacks, + TEXT_RASTER_PADDING, +} from 'features/controlLayers/text/textConstants'; import { buildFontDescriptor, calculateLayerPosition, @@ -15,7 +19,10 @@ import { type TextMeasureConfig, } from 'features/controlLayers/text/textRenderer'; import { type TextSessionStatus, transitionTextSessionStatus } from 'features/controlLayers/text/textSessionMachine'; +import { awaitUserFontReady } from 'features/controlLayers/text/textUserFonts'; +import { toast } from 'features/toast/toast'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; +import { t } from 'i18next'; import Konva from 'konva'; import type { KonvaEventObject } from 'konva/lib/Node'; import { atom } from 'nanostores'; @@ -117,6 +124,12 @@ export class CanvasTextToolModule extends CanvasModuleBase { this.render(); }) ); + this.subscriptions.add( + subscribeToCustomTextFontStacks(() => { + this.cursorMetricsKey = null; + this.render(); + }) + ); } destroy = () => { @@ -351,6 +364,25 @@ export class CanvasTextToolModule extends CanvasModuleBase { textSettings: CanvasTextSettingsState, color: RgbaColor ) => { + const fontReadiness = await awaitUserFontReady(textSettings.fontId); + if (fontReadiness !== 'ready') { + const currentSession = this.$session.get(); + if (currentSession?.id === session.id) { + this.$session.set({ ...currentSession, status: 'editing' }); + } + const isTimeout = fontReadiness === 'timeout'; + toast({ + id: isTimeout + ? `custom-font-still-loading:${textSettings.fontId}` + : `custom-font-load-failed:${textSettings.fontId}`, + status: 'error', + title: t(isTimeout ? 'toast.customFontStillLoading' : 'toast.customFontLoadFailed'), + description: t(isTimeout ? 'toast.customFontStillLoadingDesc' : 'toast.customFontUnavailableDesc'), + withCount: false, + }); + return; + } + if (typeof document !== 'undefined' && document.fonts?.load) { const fontSpec = buildFontDescriptor({ fontFamily: getFontStackById(textSettings.fontId), diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textConstants.test.ts b/invokeai/frontend/web/src/features/controlLayers/text/textConstants.test.ts new file mode 100644 index 00000000000..2abb85e243b --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/text/textConstants.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + getFontStackById, + getTextFontStack, + setCustomTextFontStacks, + subscribeToCustomTextFontStacks, + TEXT_FONT_STACKS, +} from './textConstants'; + +describe('textConstants custom font registry', () => { + afterEach(() => { + setCustomTextFontStacks([]); + }); + + it('notifies subscribers when custom fonts are updated', () => { + const listener = vi.fn(); + const unsubscribe = subscribeToCustomTextFontStacks(listener); + + setCustomTextFontStacks([{ id: 'user:fonts/MyFont-Regular.ttf', label: 'My Font', stack: '"My Font",sans-serif' }]); + + expect(listener).toHaveBeenCalledTimes(1); + expect(getFontStackById('user:fonts/MyFont-Regular.ttf')).toBe('"My Font",sans-serif'); + + unsubscribe(); + }); + + it('distinguishes missing custom font ids from known font stacks', () => { + expect(getTextFontStack('user:missing-font')).toBeUndefined(); + expect(getFontStackById('user:missing-font')).toBe(TEXT_FONT_STACKS[0]?.stack ?? 'sans-serif'); + }); +}); diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts b/invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts index 0b85c27c49c..2b5a8d1ae62 100644 --- a/invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts +++ b/invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts @@ -1,21 +1,13 @@ +import { atom } from 'nanostores'; import { z } from 'zod'; -const TEXT_FONT_IDS = [ - 'sans', - 'serif', - 'mono', - 'rounded', - 'script', - 'humanist', - 'slab', - 'display', - 'narrow', - 'uiSerif', -] as const; -export const zTextFontId = z.enum(TEXT_FONT_IDS); -export type TextFontId = z.infer; - -export const TEXT_FONT_STACKS: Array<{ id: TextFontId; label: string; stack: string }> = [ +// Custom font IDs are server-generated paths, so this cannot be restricted to the built-in font ID enum. +export const zTextFontId = z.string().min(1); +export type TextFontId = string; + +type TextFontStack = { id: TextFontId; label: string; stack: string }; + +export const TEXT_FONT_STACKS: Array = [ { id: 'sans', label: 'Sans', @@ -69,6 +61,24 @@ export const TEXT_FONT_STACKS: Array<{ id: TextFontId; label: string; stack: str }, ]; +export const $customTextFontStacks = atom>([]); + +export const setCustomTextFontStacks = (fonts: Array) => { + $customTextFontStacks.set(fonts); +}; + +export const subscribeToCustomTextFontStacks = (listener: (fonts: readonly TextFontStack[]) => void) => { + return $customTextFontStacks.listen((fonts) => listener(fonts)); +}; + +const getAllTextFontStacks = (): Array => { + const customTextFontStacks = $customTextFontStacks.get(); + if (customTextFontStacks.length === 0) { + return TEXT_FONT_STACKS; + } + return [...customTextFontStacks, ...TEXT_FONT_STACKS]; +}; + export const TEXT_DEFAULT_FONT_ID: TextFontId = 'sans'; export const TEXT_DEFAULT_FONT_SIZE = 48; export const TEXT_MIN_FONT_SIZE = 1; @@ -139,6 +149,21 @@ export const resolveAvailableFont = (stack: string): string => { return fontCandidates[0] ?? 'sans-serif'; }; +export const isCustomTextFontId = (fontId: TextFontId): boolean => fontId.startsWith('user:'); + +export const getTextFontStack = (fontId: TextFontId): TextFontStack | undefined => { + const fonts = getAllTextFontStacks(); + const exactMatch = fonts.find((font) => font.id === fontId); + if (exactMatch) { + return exactMatch; + } + if (!isCustomTextFontId(fontId)) { + return undefined; + } + const legacyFamilyId = fontId.slice(5).trim().toLowerCase(); + return fonts.find((font) => font.label.trim().toLowerCase() === legacyFamilyId); +}; + export const getFontStackById = (fontId: TextFontId): string => { - return TEXT_FONT_STACKS.find((font) => font.id === fontId)?.stack ?? TEXT_FONT_STACKS[0]?.stack ?? 'sans-serif'; + return getTextFontStack(fontId)?.stack ?? TEXT_FONT_STACKS[0]?.stack ?? 'sans-serif'; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textUserFonts.test.ts b/invokeai/frontend/web/src/features/controlLayers/text/textUserFonts.test.ts new file mode 100644 index 00000000000..5ecb3d1693f --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/text/textUserFonts.test.ts @@ -0,0 +1,270 @@ +import type { UserFont } from 'services/api/endpoints/utilities'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + awaitUserFontReady, + buildCustomTextFontStacks, + clearUserFontRegistryForTests, + getUserFontFaceKey, + isUserFontReady, + loadedUserFontFaces, + syncUserFontFaces, +} from './textUserFonts'; + +describe('textUserFonts', () => { + const face = { + path: 'fonts/MyFont-Regular.ttf', + url: 'api/v1/utilities/fonts/fonts/MyFont-Regular.ttf', + weight: 400, + style: 'normal' as const, + }; + + const font: UserFont = { + id: 'user:fonts/MyFont-Regular.ttf', + family: 'My Font', + label: 'My Font', + path: 'fonts/MyFont-Regular.ttf', + url: 'api/v1/utilities/fonts/fonts/MyFont-Regular.ttf', + faces: [face], + }; + + afterEach(() => { + clearUserFontRegistryForTests(); + }); + + it('builds custom font stacks from user fonts', () => { + expect(buildCustomTextFontStacks([font])).toEqual([ + { + id: font.id, + label: font.label, + stack: '"My Font",sans-serif', + }, + ]); + }); + + it('loads authenticated font faces and prunes stale entries', async () => { + const staleFace = { family: 'Stale Font' }; + const loadedFontFaces = new Map([['stale|400|normal|/stale.ttf', staleFace]]); + const addedFaces: object[] = []; + const deletedFaces: object[] = []; + const loadedFace = { family: 'My Font' }; + const fetchFn = vi.fn(() => + Promise.resolve({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + }) + ); + const fontFaceCtor = vi.fn(function () { + return { + load: () => Promise.resolve(loadedFace), + }; + }); + + await syncUserFontFaces({ + fonts: [font], + token: 'test-token', + baseUrl: 'https://invoke.example.com/subpath', + loadedFontFaces, + fontFaceSet: { + add: (face) => addedFaces.push(face), + delete: (face) => { + deletedFaces.push(face); + return true; + }, + }, + fontFaceCtor, + fetchFn, + }); + + const faceKey = getUserFontFaceKey(font, face); + + expect(fetchFn).toHaveBeenCalledWith(`https://invoke.example.com/subpath/${face.url}`, { + headers: { Authorization: 'Bearer test-token' }, + }); + expect(fontFaceCtor).toHaveBeenCalledWith('My Font', expect.any(ArrayBuffer), { + weight: '400', + style: 'normal', + }); + expect(addedFaces).toEqual([loadedFace]); + expect(deletedFaces).toEqual([staleFace]); + expect(loadedFontFaces.get(faceKey)).toBe(loadedFace); + expect(loadedFontFaces.has('stale|400|normal|/stale.ttf')).toBe(false); + }); + + it('reuses the module-level font face registry across sync cycles', async () => { + const loadedFace = { family: 'My Font' } as FontFace; + const addedFaces: FontFace[] = []; + const fetchFn = vi.fn(() => + Promise.resolve({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + }) + ); + const fontFaceCtor = vi.fn(function () { + return { + load: () => Promise.resolve(loadedFace), + }; + }); + const args = { + fonts: [font], + token: null, + baseUrl: 'https://invoke.example.com', + loadedFontFaces: loadedUserFontFaces, + fontFaceSet: { + add: (loadedFace: FontFace) => addedFaces.push(loadedFace), + delete: () => true, + }, + fontFaceCtor, + fetchFn, + }; + + await syncUserFontFaces(args); + await syncUserFontFaces(args); + + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(addedFaces).toEqual([loadedFace]); + }); + + it('tracks custom font readiness until all faces load', async () => { + const loadedFontFaces = new Map(); + const loadedFace = { family: 'My Font' }; + let resolveFetch: ((response: { ok: boolean; arrayBuffer: () => Promise }) => void) | undefined; + const fetchFn = vi.fn( + () => + new Promise<{ ok: boolean; arrayBuffer: () => Promise }>((resolve) => { + resolveFetch = resolve; + }) + ); + const fontFaceCtor = vi.fn(function () { + return { + load: () => Promise.resolve(loadedFace), + }; + }); + + const syncPromise = syncUserFontFaces({ + fonts: [font], + token: null, + baseUrl: 'https://invoke.example.com', + loadedFontFaces, + fontFaceSet: { + add: () => undefined, + delete: () => true, + }, + fontFaceCtor, + fetchFn, + }); + + expect(isUserFontReady(font.id)).toBe(false); + + const readyPromise = awaitUserFontReady(font.id); + let isReadyResolved = false; + void readyPromise.then(() => { + isReadyResolved = true; + }); + + await Promise.resolve(); + expect(isReadyResolved).toBe(false); + + resolveFetch?.({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + }); + + await syncPromise; + await expect(readyPromise).resolves.toBe('ready'); + + expect(isUserFontReady(font.id)).toBe(true); + }); + + it('reports timeout while a custom font is still pending', async () => { + vi.useFakeTimers(); + + try { + const loadedFontFaces = new Map(); + const fetchFn = vi.fn( + () => + new Promise<{ ok: boolean; arrayBuffer: () => Promise }>(() => { + // Keep the font request pending so readiness must time out. + }) + ); + const fontFaceCtor = vi.fn(function () { + return { + load: () => Promise.resolve({ family: 'My Font' }), + }; + }); + + void syncUserFontFaces({ + fonts: [font], + token: null, + baseUrl: 'https://invoke.example.com', + loadedFontFaces, + fontFaceSet: { + add: () => undefined, + delete: () => true, + }, + fontFaceCtor, + fetchFn, + }); + + await Promise.resolve(); + const readinessPromise = awaitUserFontReady(font.id); + await vi.advanceTimersByTimeAsync(2000); + + await expect(readinessPromise).resolves.toBe('timeout'); + expect(isUserFontReady(font.id)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + it('allows a later sync to recover from an initial load failure', async () => { + const loadedFontFaces = new Map(); + const loadedFace = { family: 'My Font' }; + const fetchFn = vi + .fn<() => Promise<{ ok: boolean; arrayBuffer: () => Promise }>>() + .mockResolvedValueOnce({ + ok: false, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + }) + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + }); + const fontFaceCtor = vi.fn(function () { + return { + load: () => Promise.resolve(loadedFace), + }; + }); + + await syncUserFontFaces({ + fonts: [font], + token: null, + baseUrl: 'https://invoke.example.com', + loadedFontFaces, + fontFaceSet: { + add: () => undefined, + delete: () => true, + }, + fontFaceCtor, + fetchFn, + }); + + expect(isUserFontReady(font.id)).toBe(false); + await expect(awaitUserFontReady(font.id)).resolves.toBe('error'); + + await syncUserFontFaces({ + fonts: [font], + token: null, + baseUrl: 'https://invoke.example.com', + loadedFontFaces, + fontFaceSet: { + add: () => undefined, + delete: () => true, + }, + fontFaceCtor, + fetchFn, + }); + + expect(fetchFn).toHaveBeenCalledTimes(2); + expect(isUserFontReady(font.id)).toBe(true); + }); +}); diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textUserFonts.ts b/invokeai/frontend/web/src/features/controlLayers/text/textUserFonts.ts new file mode 100644 index 00000000000..da92ba80838 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/text/textUserFonts.ts @@ -0,0 +1,242 @@ +import { isCustomTextFontId, type TextFontId } from 'features/controlLayers/text/textConstants'; +import { atom } from 'nanostores'; +import type { UserFont, UserFontFace } from 'services/api/endpoints/utilities'; + +type CustomTextFontStack = { id: TextFontId; label: string; stack: string }; +type UserFontReadyState = 'pending' | 'ready' | 'error'; +type UserFontReadinessResult = Exclude | 'timeout'; + +type FetchResponseLike = { + ok: boolean; + arrayBuffer: () => Promise; +}; + +type FetchLike = (input: string, init?: RequestInit) => Promise; + +type FontFaceLike = { + load: () => Promise; +}; + +type FontFaceConstructorLike = new ( + family: string, + source: string | ArrayBuffer, + descriptors?: { weight?: string; style?: string } +) => FontFaceLike; + +type FontFaceSetLike = { + add: (fontFace: TLoadedFontFace) => unknown; + delete: (fontFace: TLoadedFontFace) => boolean; +}; + +export const $userFontReadyStates = atom>({}); +export const loadedUserFontFaces = new Map(); +const userFontReadyPromises = new Map>(); +const userFontReadyResolvers = new Map void>(); +const USER_FONT_READY_TIMEOUT_MS = 2000; + +export const buildCustomTextFontStacks = (fonts: Array): Array => { + return fonts.map((font) => ({ + id: font.id, + label: font.label, + stack: `"${font.family}",sans-serif`, + })); +}; + +export const getUserFontFaceKey = ( + font: Pick, + face: Pick +) => `${font.family}|${face.weight}|${face.style}|${face.url}`; + +const areAllFontFacesLoaded = ( + font: UserFont, + loadedFontFaces: Map +): boolean => { + return font.faces.every((face) => loadedFontFaces.has(getUserFontFaceKey(font, face))); +}; + +const setUserFontReadyState = (fontId: TextFontId, state: UserFontReadyState): void => { + const currentStates = $userFontReadyStates.get(); + if (currentStates[fontId] === state) { + return; + } + $userFontReadyStates.set({ + ...currentStates, + [fontId]: state, + }); +}; + +const deleteUserFontReadyState = (fontId: TextFontId): void => { + const currentStates = $userFontReadyStates.get(); + if (!(fontId in currentStates)) { + return; + } + const nextStates = { ...currentStates }; + delete nextStates[fontId]; + $userFontReadyStates.set(nextStates); +}; + +const ensureUserFontReadyPromise = (fontId: TextFontId): Promise => { + const existingPromise = userFontReadyPromises.get(fontId); + if (existingPromise) { + return existingPromise; + } + + let resolvePromise: (() => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + userFontReadyPromises.set(fontId, promise); + userFontReadyResolvers.set(fontId, resolvePromise ?? (() => undefined)); + return promise; +}; + +const settleUserFontReadyPromise = (fontId: TextFontId): void => { + userFontReadyResolvers.get(fontId)?.(); + userFontReadyResolvers.delete(fontId); + userFontReadyPromises.delete(fontId); +}; + +export const primeUserFontReadiness = ( + fonts: Array, + loadedFontFaces: Map +): void => { + const activeFontIds = new Set(fonts.map((font) => font.id)); + + for (const fontId of Object.keys($userFontReadyStates.get()) as TextFontId[]) { + if (isCustomTextFontId(fontId) && !activeFontIds.has(fontId)) { + deleteUserFontReadyState(fontId); + settleUserFontReadyPromise(fontId); + } + } + + for (const font of fonts) { + if (areAllFontFacesLoaded(font, loadedFontFaces)) { + setUserFontReadyState(font.id, 'ready'); + settleUserFontReadyPromise(font.id); + continue; + } + + setUserFontReadyState(font.id, 'pending'); + ensureUserFontReadyPromise(font.id); + } +}; + +export const isUserFontReady = (fontId: TextFontId): boolean => { + return !isCustomTextFontId(fontId) || $userFontReadyStates.get()[fontId] === 'ready'; +}; + +export const awaitUserFontReady = async (fontId: TextFontId): Promise => { + if (!isCustomTextFontId(fontId)) { + return 'ready'; + } + const state = $userFontReadyStates.get()[fontId]; + if (state === 'ready' || state === 'error') { + return state; + } + let timeoutId: ReturnType | undefined; + try { + return await Promise.race([ + ensureUserFontReadyPromise(fontId).then(() => { + const settledState = $userFontReadyStates.get()[fontId]; + return settledState === 'ready' || settledState === 'error' ? settledState : 'timeout'; + }), + new Promise<'timeout'>((resolve) => { + timeoutId = setTimeout(() => resolve('timeout'), USER_FONT_READY_TIMEOUT_MS); + }), + ]); + } finally { + clearTimeout(timeoutId); + } +}; + +export const clearUserFontRegistryForTests = (): void => { + $userFontReadyStates.set({}); + userFontReadyPromises.clear(); + loadedUserFontFaces.clear(); + userFontReadyResolvers.clear(); +}; + +type SyncUserFontFacesArgs = { + fonts: Array; + token: string | null; + loadedFontFaces: Map; + baseUrl: string; + fontFaceSet: FontFaceSetLike; + fontFaceCtor: FontFaceConstructorLike; + fetchFn: FetchLike; +}; + +export async function syncUserFontFaces({ + fonts, + token, + loadedFontFaces, + baseUrl, + fontFaceSet, + fontFaceCtor, + fetchFn, +}: SyncUserFontFacesArgs): Promise { + const activeFaceKeys = new Set(fonts.flatMap((font) => font.faces.map((face) => getUserFontFaceKey(font, face)))); + + for (const [faceKey, fontFace] of loadedFontFaces.entries()) { + if (activeFaceKeys.has(faceKey)) { + continue; + } + fontFaceSet.delete(fontFace); + loadedFontFaces.delete(faceKey); + } + + await Promise.all( + fonts.map(async (font) => { + if (areAllFontFacesLoaded(font, loadedFontFaces)) { + setUserFontReadyState(font.id, 'ready'); + settleUserFontReadyPromise(font.id); + return; + } + + setUserFontReadyState(font.id, 'pending'); + ensureUserFontReadyPromise(font.id); + let hadFailure = false; + + await Promise.all( + font.faces.map(async (face) => { + const faceKey = getUserFontFaceKey(font, face); + if (loadedFontFaces.has(faceKey)) { + return; + } + + try { + const fontUrl = `${baseUrl.replace(/\/$/, '')}/${face.url.replace(/^\//, '')}`; + const response = await fetchFn(fontUrl, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + if (!response.ok) { + hadFailure = true; + return; + } + + const fontBuffer = await response.arrayBuffer(); + const fontFace = new fontFaceCtor(font.family, fontBuffer, { + weight: String(face.weight), + style: face.style, + }); + const loadedFontFace = await fontFace.load(); + fontFaceSet.add(loadedFontFace); + loadedFontFaces.set(faceKey, loadedFontFace); + } catch { + hadFailure = true; + } + }) + ); + + if (areAllFontFacesLoaded(font, loadedFontFaces)) { + setUserFontReadyState(font.id, 'ready'); + } else if (hadFailure) { + setUserFontReadyState(font.id, 'error'); + } + + if ($userFontReadyStates.get()[font.id] !== 'pending') { + settleUserFontReadyPromise(font.id); + } + }) + ); +} diff --git a/invokeai/frontend/web/src/services/api/endpoints/utilities.ts b/invokeai/frontend/web/src/services/api/endpoints/utilities.ts index 2c817205762..a2b89c65347 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/utilities.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/utilities.ts @@ -2,6 +2,26 @@ import type { paths } from 'services/api/schema'; import { api, buildV1Url } from '..'; +export type UserFontFace = { + path: string; + url: string; + weight: number; + style: 'normal' | 'italic'; +}; + +export type UserFont = { + id: string; + family: string; + label: string; + path: string; + url: string; + faces: UserFontFace[]; +}; + +type UserFontsResponse = { + fonts: UserFont[]; +}; + /** * Builds an endpoint URL for the utilities router * @example @@ -54,6 +74,13 @@ export const utilitiesApi = api.injectEndpoints({ // disconnected. providesTags: ['FetchOnReconnect'], }), + listUserFonts: build.query({ + query: () => ({ + url: buildUtilitiesUrl('fonts'), + }), + transformResponse: (response: UserFontsResponse) => response.fonts, + providesTags: ['FetchOnReconnect'], + }), expandPrompt: build.mutation({ query: (arg) => ({ url: buildUtilitiesUrl('expand-prompt'), @@ -71,4 +98,4 @@ export const utilitiesApi = api.injectEndpoints({ }), }); -export const { useExpandPromptMutation, useImageToPromptMutation } = utilitiesApi; +export const { useListUserFontsQuery, useExpandPromptMutation, useImageToPromptMutation } = utilitiesApi; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 44e470dc49d..a91efcaf01e 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -344,6 +344,40 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/utilities/fonts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List User Fonts */ + get: operations["list_user_fonts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/utilities/fonts/{font_path}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Font File */ + get: operations["get_user_font_file"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/utilities/expand-prompt": { parameters: { query?: never; @@ -18973,6 +19007,13 @@ export type components = { * @default outputs */ outputs_dir?: string; + /** + * Fonts Dir + * Format: path + * @description Path to directory for custom fonts. + * @default fonts + */ + fonts_dir?: string; /** * Image Subfolder Strategy * @description Strategy for organizing images into subfolders. 'flat' stores all images in a single folder. 'date' organizes by YYYY/MM/DD. 'type' organizes by image category. 'hash' uses first 2 characters of UUID for filesystem performance. @@ -37403,6 +37444,37 @@ export type components = { */ last_login_at?: string | null; }; + /** UserFont */ + UserFont: { + /** Id */ + id: string; + /** Family */ + family: string; + /** Label */ + label: string; + /** Path */ + path: string; + /** Url */ + url: string; + /** Faces */ + faces: components["schemas"]["UserFontFace"][]; + }; + /** UserFontFace */ + UserFontFace: { + /** Path */ + path: string; + /** Url */ + url: string; + /** Weight */ + weight: number; + /** Style */ + style: string; + }; + /** UserFontsResponse */ + UserFontsResponse: { + /** Fonts */ + fonts: components["schemas"]["UserFont"][]; + }; /** * UserProfileUpdateRequest * @description Request body for a user to update their own profile. @@ -42180,6 +42252,57 @@ export interface operations { }; }; }; + list_user_fonts: { + 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"]["UserFontsResponse"]; + }; + }; + }; + }; + get_user_font_file: { + parameters: { + query?: never; + header?: never; + path: { + font_path: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; expand_prompt: { parameters: { query?: never; diff --git a/pyproject.toml b/pyproject.toml index 06b481f5996..63664bcae43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ dependencies = [ "dynamicprompts", "einops", "email-validator>=2.0.0", + "fonttools[woff]", "passlib[bcrypt]>=1.7.4", "picklescan", "pillow", diff --git a/tests/app/routers/test_utilities.py b/tests/app/routers/test_utilities.py index 84a69696881..8953877b2a2 100644 --- a/tests/app/routers/test_utilities.py +++ b/tests/app/routers/test_utilities.py @@ -7,12 +7,15 @@ - image-to-prompt: a missing image surfaces as 404, not 500. """ +import shutil +from pathlib import Path from typing import Any from unittest.mock import MagicMock import pytest from fastapi import status from fastapi.testclient import TestClient +from fontTools.ttLib import TTFont from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin from invokeai.app.services.invoker import Invoker @@ -39,6 +42,12 @@ def _create_extra_user(mock_invoker: Invoker, email: str) -> str: return user.user_id +@pytest.fixture +def font_root(mock_invoker: Invoker, invokeai_root_dir: Path) -> Path: + mock_invoker.services.configuration._root = invokeai_root_dir + return invokeai_root_dir + + # ----------------------------- Auth gating ----------------------------- @@ -171,3 +180,155 @@ def test_image_to_prompt_admin_can_access_any_image( ) # Admin passes the read-access check; model loading then fails with 404. assert r.status_code == status.HTTP_404_NOT_FOUND + + +def test_list_user_fonts_requires_auth(enable_multiuser: Any, font_root: Path, client: TestClient) -> None: + fonts_dir = font_root / "fonts" + fonts_dir.mkdir(parents=True, exist_ok=True) + (fonts_dir / "MyFont.ttf").write_bytes(b"not-a-real-font") + + r = client.get("/api/v1/utilities/fonts") + + assert r.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_get_user_font_file_requires_auth(enable_multiuser: Any, font_root: Path, client: TestClient) -> None: + fonts_dir = font_root / "fonts" + fonts_dir.mkdir(parents=True, exist_ok=True) + (fonts_dir / "MyFont.ttf").write_bytes(b"not-a-real-font") + + r = client.get("/api/v1/utilities/fonts/MyFont.ttf") + + assert r.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_user_fonts_support_real_font_files_and_configured_directory( + admin_token: str, client: TestClient, font_root: Path, mock_invoker: Invoker +) -> None: + assert mock_invoker.services.configuration.root_path == font_root + mock_invoker.services.configuration.fonts_dir = Path("custom-fonts") + fonts_dir = mock_invoker.services.configuration.fonts_path + fonts_dir.mkdir(parents=True, exist_ok=True) + source_font = Path(__file__).parents[3] / "invokeai" / "assets" / "fonts" / "inter" / "Inter-Regular.ttf" + shutil.copyfile(source_font, fonts_dir / "Inter-Regular.ttf") + + r = client.get("/api/v1/utilities/fonts", headers={"Authorization": f"Bearer {admin_token}"}) + + assert r.status_code == status.HTTP_200_OK + body = r.json() + assert len(body["fonts"]) == 1 + assert body["fonts"][0]["family"] == "Inter" + assert body["fonts"][0]["url"] == "api/v1/utilities/fonts/Inter-Regular.ttf" + + font_response = client.get( + "/api/v1/utilities/fonts/Inter-Regular.ttf", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert font_response.status_code == status.HTTP_200_OK + assert font_response.headers["content-type"] == "font/ttf" + assert font_response.headers["cache-control"] == "private, max-age=31536000, immutable" + assert font_response.headers["content-disposition"].startswith('inline; filename="Inter-Regular.ttf"') + assert font_response.content == source_font.read_bytes() + + +def test_list_user_fonts_reads_real_woff2_file( + admin_token: str, client: TestClient, mock_invoker: Invoker, tmp_path: Path +) -> None: + mock_invoker.services.configuration.fonts_dir = tmp_path / "fonts" + fonts_dir = mock_invoker.services.configuration.fonts_path + fonts_dir.mkdir(parents=True, exist_ok=True) + source_font = Path(__file__).parents[3] / "invokeai" / "assets" / "fonts" / "inter" / "Inter-Regular.ttf" + font = TTFont(source_font) + try: + font.flavor = "woff2" + font.save(fonts_dir / "Inter-Regular.woff2") + finally: + font.close() + + r = client.get("/api/v1/utilities/fonts", headers={"Authorization": f"Bearer {admin_token}"}) + + assert r.status_code == status.HTTP_200_OK + body = r.json() + assert len(body["fonts"]) == 1 + assert body["fonts"][0]["family"] == "Inter" + assert body["fonts"][0]["faces"][0]["path"] == "Inter-Regular.woff2" + + +def test_list_user_fonts_allows_authenticated_access( + admin_token: str, client: TestClient, font_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + fonts_dir = font_root / "fonts" + fonts_dir.mkdir(parents=True, exist_ok=True) + (fonts_dir / "MyFont.ttf").write_bytes(b"not-a-real-font") + monkeypatch.setattr( + "invokeai.app.api.routers.utilities._get_font_metadata", + lambda _font_file: ("My Font", "My Font", 400, "normal"), + ) + + r = client.get("/api/v1/utilities/fonts", headers={"Authorization": f"Bearer {admin_token}"}) + + assert r.status_code == status.HTTP_200_OK + body = r.json() + assert len(body["fonts"]) == 1 + assert body["fonts"][0]["id"] == "user:MyFont.ttf" + + +def test_list_user_fonts_skips_malformed_fonts_and_logs_warning( + admin_token: str, + client: TestClient, + font_root: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + fonts_dir = font_root / "fonts" + fonts_dir.mkdir(parents=True, exist_ok=True) + (fonts_dir / "BrokenFont.ttf").write_bytes(b"not-a-real-font") + + with caplog.at_level("WARNING"): + r = client.get("/api/v1/utilities/fonts", headers={"Authorization": f"Bearer {admin_token}"}) + + assert r.status_code == status.HTTP_200_OK + assert r.json()["fonts"] == [] + assert "Skipping font file" in caplog.text + + +def test_get_user_font_file_rejects_symlink( + admin_token: str, client: TestClient, font_root: Path, tmp_path: Path +) -> None: + fonts_dir = font_root / "fonts" + fonts_dir.mkdir(parents=True, exist_ok=True) + outside_file = tmp_path / "outside.ttf" + outside_file.write_bytes(b"outside-font") + symlink_path = fonts_dir / "linked.ttf" + + try: + symlink_path.symlink_to(outside_file) + except (NotImplementedError, OSError): + pytest.skip("Symlinks are not available in this test environment") + + r = client.get("/api/v1/utilities/fonts/linked.ttf", headers={"Authorization": f"Bearer {admin_token}"}) + + assert r.status_code == status.HTTP_400_BAD_REQUEST + + +def test_list_user_fonts_skips_symlinked_files( + admin_token: str, client: TestClient, font_root: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + fonts_dir = font_root / "fonts" + fonts_dir.mkdir(parents=True, exist_ok=True) + outside_dir = tmp_path / "outside-fonts" + outside_dir.mkdir() + (outside_dir / "outside.ttf").write_bytes(b"outside-font") + symlink_path = fonts_dir / "linked-dir" + + try: + symlink_path.symlink_to(outside_dir, target_is_directory=True) + except (NotImplementedError, OSError): + pytest.skip("Symlinks are not available in this test environment") + + with caplog.at_level("WARNING"): + r = client.get("/api/v1/utilities/fonts", headers={"Authorization": f"Bearer {admin_token}"}) + + assert r.status_code == status.HTTP_200_OK + assert r.json()["fonts"] == [] + assert "Skipping font path" in caplog.text diff --git a/tests/app/util/test_custom_openapi.py b/tests/app/util/test_custom_openapi.py index 36b791ab48c..e0f054917a2 100644 --- a/tests/app/util/test_custom_openapi.py +++ b/tests/app/util/test_custom_openapi.py @@ -2,7 +2,8 @@ from pydantic import create_model from invokeai.app.invocations.baseinvocation import InvocationRegistry -from invokeai.app.util.custom_openapi import get_openapi_func +from invokeai.app.services.config.config_default import InvokeAIAppConfig +from invokeai.app.util.custom_openapi import get_openapi_func, normalize_path_defaults class _FakeOutput: @@ -59,3 +60,31 @@ def test_invocation_output_map_required_is_sorted(monkeypatch: object) -> None: required = schema["components"]["schemas"]["InvocationOutputMap"]["required"] assert required == ["a_type", "b_type"], f"Expected sorted required list, got: {required}" + + +def test_path_defaults_are_normalized_to_forward_slashes() -> None: + schema = InvokeAIAppConfig.model_json_schema() + schema["properties"]["convert_cache_dir"]["default"] = "models\\.convert_cache" + schema["properties"]["download_cache_dir"]["default"] = "models\\.download_cache" + schema["properties"]["nested_path"] = { + "oneOf": [ + { + "type": "object", + "properties": { + "cache_dir": { + "type": "string", + "format": "path", + "default": "models\\.nested_cache", + } + }, + } + ] + } + + normalize_path_defaults(schema) + + assert schema["properties"]["convert_cache_dir"]["default"] == "models/.convert_cache" + assert schema["properties"]["download_cache_dir"]["default"] == "models/.download_cache" + assert ( + schema["properties"]["nested_path"]["oneOf"][0]["properties"]["cache_dir"]["default"] == "models/.nested_cache" + ) diff --git a/tests/test_config.py b/tests/test_config.py index 78d5dbe3466..8a90db91574 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,6 +9,7 @@ from invokeai.app.services.config.config_default import ( DefaultInvokeAIAppConfig, InvokeAIAppConfig, + ensure_fonts_dir, get_config, load_and_migrate_config, ) @@ -278,6 +279,8 @@ def test_get_config_writing(patch_rootdir: None, monkeypatch: pytest.MonkeyPatch assert config.config_file_path == config_file_path assert config_file_path.exists() assert example_file_path.exists() + assert (tmp_path / "fonts").exists() + assert (tmp_path / "fonts" / "README.txt").exists() # The example file should have the default values example_file_content = example_file_path.read_text() @@ -295,6 +298,25 @@ def test_get_config_writing(patch_rootdir: None, monkeypatch: pytest.MonkeyPatch InvokeAIArgs.did_parse = False +def test_ensure_fonts_dir_logs_warning_on_oserror( + patch_rootdir: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture +): + original_mkdir = Path.mkdir + fonts_path = tmp_path / "fonts" + + def mock_mkdir(self: Path, *args: Any, **kwargs: Any) -> None: + if self == fonts_path: + raise OSError("read-only") + original_mkdir(self, *args, **kwargs) + + monkeypatch.setattr(Path, "mkdir", mock_mkdir) + + with caplog.at_level("WARNING"): + ensure_fonts_dir(fonts_path) + + assert "Unable to initialize fonts directory" in caplog.text + + def test_get_config_reads_external_api_keys_file(patch_rootdir: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path): """Test that API keys are loaded from the dedicated api_keys.yaml file.""" InvokeAIArgs.did_parse = True diff --git a/uv.lock b/uv.lock index 1e337cace0d..7269523a029 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11, <3.13" resolution-markers = [ "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", @@ -344,6 +344,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/c3/e8effb323af40a5d8e85ab557da2f0475f12a31f3266f28d91d0bf406963/blessed-1.44.0-py3-none-any.whl", hash = "sha256:e1d2ed93d3d90d0a1494a8b134d188c83fb89ae25590af638b921e8c1c8fa223", size = 130232, upload-time = "2026-05-24T02:06:26.458Z" }, ] +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, +] + +[[package]] +name = "brotlicffi" +version = "1.2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265, upload-time = "2026-03-05T19:54:05.858Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710, upload-time = "2026-03-05T19:54:07.272Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787, upload-time = "2026-03-05T19:54:08.73Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, +] + [[package]] name = "build" version = "1.5.0" @@ -914,6 +962,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, ] +[package.optional-dependencies] +woff = [ + { name = "brotli", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_python_implementation == 'CPython' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, + { name = "brotlicffi", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and platform_python_implementation != 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86_64' and platform_python_implementation != 'CPython' and sys_platform == 'linux') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_python_implementation != 'CPython' and sys_platform == 'darwin') or (platform_python_implementation != 'CPython' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, + { name = "zopfli", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, +] + [[package]] name = "fqdn" version = "1.5.1" @@ -1174,6 +1229,7 @@ dependencies = [ { name = "email-validator", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, { name = "fastapi", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, { name = "fastapi-events", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, + { name = "fonttools", extra = ["woff"], marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, { name = "gguf", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, { name = "huggingface-hub", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, { name = "networkx", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, @@ -1288,6 +1344,7 @@ requires-dist = [ { name = "email-validator", specifier = ">=2.0.0" }, { name = "fastapi", specifier = "==0.118.3" }, { name = "fastapi-events" }, + { name = "fonttools", extras = ["woff"] }, { name = "gguf" }, { name = "gprof2dot", marker = "extra == 'dev'" }, { name = "httpx", marker = "extra == 'test'" }, @@ -1531,7 +1588,7 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, + { name = "markupsafe", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ @@ -4063,7 +4120,7 @@ name = "sympy" version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mpmath", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32' or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, + { name = "mpmath", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } wheels = [ @@ -4188,10 +4245,10 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'darwin'", ] dependencies = [ - { name = "filelock", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'darwin' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm')" }, - { name = "fsspec", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'darwin' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm')" }, - { name = "jinja2", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'darwin' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm')" }, - { name = "networkx", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'darwin' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm')" }, + { name = "filelock", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" }, + { name = "fsspec", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" }, + { name = "jinja2", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" }, + { name = "networkx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" }, { name = "nvidia-cublas-cu12", version = "12.6.4.1", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, { name = "nvidia-cuda-cupti-cu12", version = "12.6.80", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, { name = "nvidia-cuda-nvrtc-cu12", version = "12.6.77", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, @@ -4206,10 +4263,10 @@ dependencies = [ { name = "nvidia-nccl-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, { name = "nvidia-nvjitlink-cu12", version = "12.6.85", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, { name = "nvidia-nvtx-cu12", version = "12.6.77", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, - { name = "setuptools", marker = "(python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (python_full_version >= '3.12' and sys_platform == 'darwin' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda') or (python_full_version >= '3.12' and sys_platform == 'win32' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, - { name = "sympy", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'darwin' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm')" }, + { name = "setuptools", marker = "(python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform == 'darwin')" }, + { name = "sympy", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" }, { name = "triton", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, - { name = "typing-extensions", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'darwin' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm')" }, + { name = "typing-extensions", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/11/56/2eae3494e3d375533034a8e8cf0ba163363e996d85f0629441fa9d9843fe/torch-2.7.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:236f501f2e383f1cb861337bdf057712182f910f10aeaf509065d54d339e49b2", size = 99093039, upload-time = "2025-06-04T17:39:06.963Z" }, @@ -4348,9 +4405,9 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'darwin'", ] dependencies = [ - { name = "numpy", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'darwin' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm')" }, - { name = "pillow", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'darwin' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm')" }, - { name = "torch", version = "2.7.1", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_machine == 'x86_64' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform != 'linux' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'darwin' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'win32' and extra != 'extra-8-invokeai-cpu' and extra != 'extra-8-invokeai-cuda' and extra != 'extra-8-invokeai-rocm')" }, + { name = "numpy", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" }, + { name = "pillow", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" }, + { name = "torch", version = "2.7.1", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f6/00/bdab236ef19da050290abc2b5203ff9945c84a1f2c7aab73e8e9c8c85669/torchvision-0.22.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4addf626e2b57fc22fd6d329cf1346d474497672e6af8383b7b5b636fba94a53", size = 1947827, upload-time = "2025-06-04T17:43:10.84Z" }, @@ -4914,3 +4971,23 @@ sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0 wheels = [ { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ] + +[[package]] +name = "zopfli" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/21/3b6af43a663b22b00e738bb0642931a2579e15da6852613d56c6aa535d28/zopfli-0.4.3.tar.gz", hash = "sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe", size = 179156, upload-time = "2026-06-10T09:10:19.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/5f/b7d81b670daf990e15a0f7551da96c3c0700f69ae6d96b0245d6a19f51f3/zopfli-0.4.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073", size = 291492, upload-time = "2026-06-10T09:10:06.621Z" }, + { url = "https://files.pythonhosted.org/packages/55/c8/d8d8d731e0b192024567b7198fb77b748821d355f3c8bf0109de27191f43/zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc", size = 829354, upload-time = "2026-06-10T09:10:07.909Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2b/fbe8ba2ec40f5986b8983a4752f7a32672a80a10ea6e68213324a7055469/zopfli-0.4.3-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206", size = 818436, upload-time = "2026-06-10T09:10:09.063Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/63568c54c8b68b9135f3456c5add83797a5528d596657f0e4f4910173b08/zopfli-0.4.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e", size = 1778931, upload-time = "2026-06-10T09:10:10.282Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/8f3aac10a858e89c2146d3a1f6ce33634c3db757365b4148fef1b85784d2/zopfli-0.4.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef", size = 1864132, upload-time = "2026-06-10T09:10:11.595Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/9ca59d14b91f9fbc631793b4b085b309777edadaca496aa518a180817827/zopfli-0.4.3-cp310-abi3-win32.whl", hash = "sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6", size = 271715, upload-time = "2026-06-10T09:10:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550, upload-time = "2026-06-10T09:10:13.639Z" }, + { url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343, upload-time = "2026-06-10T09:10:14.72Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bf/403da5a753731d9a4e4d65a494c4a9ae5a0fe62e7afffe5ab49915adc9a3/zopfli-0.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0087c9a6f0c8a052be0f6d1a9bb71b6caffdd3e10201d6d6166e28d482cebe6d", size = 147045, upload-time = "2026-06-10T09:10:15.883Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5f/afaa18db62ab44da01a3fc39b6cb110478d26cd2287baa83461c6454ed45/zopfli-0.4.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2e0adcf7d36c6fd0dd36cc771ef7f0c5803a05666feafcd90d7170174a4148e", size = 127265, upload-time = "2026-06-10T09:10:16.911Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/76bdfd8b35300391666b090357d059ce4c555b9d9ce9878dd551a9ad63a0/zopfli-0.4.3-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f51dd1ab5312e837e2091284e0d9f1a138188f2e65812f9a5799dc02c45f94", size = 124288, upload-time = "2026-06-10T09:10:17.891Z" }, + { url = "https://files.pythonhosted.org/packages/c5/95/5781bfb29782c39918686070dbf2ad1425c21384c3223f5c6bd911a806f8/zopfli-0.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:62248dbf8dbcbd588ee194b210e5be9fa80bce29641f55599d6d394bd2a9d8a3", size = 304624, upload-time = "2026-06-10T09:10:18.944Z" }, +]