From 5df99059f68b6c1239101476b68999dca88da20a Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sat, 17 Oct 2026 00:19:04 +0200 Subject: [PATCH] ci(tests): cut redundant test work and keep the font worker off the database layer The font worker imports its byte helpers from fonts_parsing instead of fonts_default, dropping SQLAlchemy and config from every spawn. Skips the windows-vacuous SIGINT subprocess test, removes the duplicate UMAP smoke, marks shape-only VAE sweeps platform_independent. Makes the upload heartbeat test deterministic and derives the concat short-remainder lengths from the keyframe position. --- invokeai/app/api/routers/fonts.py | 10 +- invokeai/app/services/fonts/fonts_common.py | 40 ++- invokeai/app/services/fonts/fonts_default.py | 235 +----------------- invokeai/app/services/fonts/fonts_parsing.py | 188 ++++++++++++++ invokeai/app/services/fonts/fonts_worker.py | 10 +- tests/app/api/test_video_upload_limits.py | 25 +- .../invocations/test_video_concat_splice.py | 47 ++-- .../app/services/fonts/test_fonts_service.py | 23 +- .../services/image_index/test_umap_import.py | 31 +-- .../test_session_processor_shutdown.py | 11 + .../stable_diffusion/test_vae_tiling.py | 3 + 11 files changed, 327 insertions(+), 296 deletions(-) create mode 100644 invokeai/app/services/fonts/fonts_parsing.py diff --git a/invokeai/app/api/routers/fonts.py b/invokeai/app/api/routers/fonts.py index 0c4ed16d14b..9dcaabea05f 100644 --- a/invokeai/app/api/routers/fonts.py +++ b/invokeai/app/api/routers/fonts.py @@ -14,19 +14,21 @@ from invokeai.app.api.auth_dependencies import AdminUserOrDefault, CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies -from invokeai.app.services.fonts.fonts_common import FontAxis, FontInstance, FontRecord, FontScope -from invokeai.app.services.fonts.fonts_default import ( - FONT_MEDIA_TYPES, +from invokeai.app.services.fonts.fonts_common import ( + FontAxis, FontChangedError, FontDeleteForbiddenError, FontForbiddenError, + FontInstance, FontInstanceError, FontNotFoundError, FontQuotaExceededError, - FontService, + FontRecord, + FontScope, FontStorageError, FontValidationError, ) +from invokeai.app.services.fonts.fonts_default import FONT_MEDIA_TYPES, FontService from invokeai.app.services.shared.pagination import MAX_PAGE_SIZE fonts_router = APIRouter(prefix="/v1/fonts", tags=["fonts"]) diff --git a/invokeai/app/services/fonts/fonts_common.py b/invokeai/app/services/fonts/fonts_common.py index dc6daeecfe5..29d9a9172d5 100644 --- a/invokeai/app/services/fonts/fonts_common.py +++ b/invokeai/app/services/fonts/fonts_common.py @@ -1,10 +1,12 @@ -"""Domain types for the managed and configured font libraries.""" +"""Domain types and errors for the managed and configured font libraries.""" from __future__ import annotations from dataclasses import dataclass from enum import StrEnum +SUPPORTED_FONT_EXTENSIONS = frozenset({".ttf", ".otf", ".woff", ".woff2"}) + class FontScope(StrEnum): """The account scope of an uploaded font.""" @@ -84,3 +86,39 @@ class FontUploadResult: font: FontRecord created: bool + + +class FontServiceError(RuntimeError): + """Base class for errors raised by the font service.""" + + +class FontNotFoundError(FontServiceError): + """The font does not exist or is not visible to the requesting account.""" + + +class FontForbiddenError(FontServiceError): + """The caller is not permitted to perform the requested operation.""" + + +class FontDeleteForbiddenError(FontForbiddenError): + """A read-only or otherwise protected font cannot be deleted.""" + + +class FontValidationError(FontServiceError): + """The file is too large, unsupported, malformed, or missing font metadata.""" + + +class FontQuotaExceededError(FontServiceError): + """An upload would exceed the configured library quota.""" + + +class FontChangedError(FontServiceError): + """A font ID no longer points at the requested content hash.""" + + +class FontInstanceError(FontServiceError): + """A variable font instance could not be produced.""" + + +class FontStorageError(FontServiceError): + """A managed font file could not be read or safely stored.""" diff --git a/invokeai/app/services/fonts/fonts_default.py b/invokeai/app/services/fonts/fonts_default.py index 385fe33acba..8105c468d98 100644 --- a/invokeai/app/services/fonts/fonts_default.py +++ b/invokeai/app/services/fonts/fonts_default.py @@ -9,7 +9,6 @@ from __future__ import annotations import hashlib -import io import json import logging import math @@ -24,22 +23,26 @@ from typing import Any from uuid import uuid4 -from fontTools.ttLib import TTFont -from fontTools.varLib.instancer import instantiateVariableFont - from invokeai.app.services.fonts.fonts_common import ( - FontAxis, - FontInstance, + SUPPORTED_FONT_EXTENSIONS, + FontChangedError, + FontDeleteForbiddenError, + FontForbiddenError, + FontInstanceError, + FontNotFoundError, + FontQuotaExceededError, FontRecord, FontScope, + FontServiceError, FontSource, + FontStorageError, FontUploadResult, + FontValidationError, FontValidationResult, ) from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.database.queries.fonts import MAX_SOURCE_PATH_LENGTH, DirectoryFont, UploadedFont -SUPPORTED_FONT_EXTENSIONS = frozenset({".ttf", ".otf", ".woff", ".woff2"}) FONT_MEDIA_TYPES = { ".ttf": "font/ttf", ".otf": "font/otf", @@ -56,192 +59,6 @@ INSTANCE_JOB_TIMEOUT_SECONDS = 30.0 -class FontServiceError(RuntimeError): - """Base class for errors raised by the font service.""" - - -class FontNotFoundError(FontServiceError): - """The font does not exist or is not visible to the requesting account.""" - - -class FontForbiddenError(FontServiceError): - """The caller is not permitted to perform the requested operation.""" - - -class FontDeleteForbiddenError(FontForbiddenError): - """A read-only or otherwise protected font cannot be deleted.""" - - -class FontValidationError(FontServiceError): - """The file is too large, unsupported, malformed, or missing font metadata.""" - - -class FontQuotaExceededError(FontServiceError): - """An upload would exceed the configured library quota.""" - - -class FontChangedError(FontServiceError): - """A font ID no longer points at the requested content hash.""" - - -class FontInstanceError(FontServiceError): - """A variable font instance could not be produced.""" - - -class FontStorageError(FontServiceError): - """A managed font file could not be read or safely stored.""" - - -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, filename: 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} {filename}") - 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, filename: str, italic_flag: bool) -> str: - if italic_flag: - return "italic" - combined = _normalize_variant_text(f"{style_name} {filename}") - return "italic" if "italic" in combined or "oblique" in combined else "normal" - - -def _get_name_table_value(font: TTFont, name_ids: tuple[int, ...]) -> str | None: - """Read a localized OpenType name, preferring English Windows records.""" - if "name" not in font: - return None - - def sort_key(record: Any) -> tuple[int, int]: - platform_id = getattr(record, "platformID", -1) - language_id = getattr(record, "langID", -1) - if platform_id == 3 and language_id in (0x0409, 0): - return (0, 0) - if platform_id == 3: - return (1, 0) - if platform_id == 0: - return (2, 0) - return (3, language_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 _parse_font_bytes(filename: str, data: bytes, *, max_bytes: int) -> FontValidationResult: - """Validate and extract metadata without persisting the supplied bytes.""" - if ( - not filename - or Path(filename).name != filename - or any(separator in filename for separator in ("/", "\\")) - or any(ord(character) < 32 for character in filename) - or filename in {".", ".."} - ): - raise FontValidationError("Font filename is invalid") - extension = Path(filename).suffix.lower() - if extension not in SUPPORTED_FONT_EXTENSIONS: - raise FontValidationError("Unsupported font format; use TTF, OTF, WOFF, or WOFF2") - if len(data) == 0: - raise FontValidationError("Font file is empty") - if len(data) > max_bytes: - raise FontValidationError(f"Font file exceeds the {max_bytes // (1024 * 1024)} MiB limit") - - try: - with TTFont(file=io.BytesIO(data), lazy=False) as font: - family = (_get_name_table_value(font, (16, 1, 4)) or "").strip() - style_name = (_get_name_table_value(font, (17, 2)) or "").strip() - label = (_get_name_table_value(font, (4,)) or family).strip() - if not family: - raise FontValidationError("Font is missing a family name") - - 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)) - weight = _infer_font_weight(style_name, filename, weight_class) - style = _infer_font_style(style_name, filename, italic_flag) - - axes: list[FontAxis] = [] - instances: list[FontInstance] = [] - if "fvar" in font: - for axis in font["fvar"].axes: - axis_tag = str(axis.axisTag) - axis_label = _get_name_table_value(font, (int(axis.axisNameID),)) or axis_tag - axes.append( - FontAxis( - tag=axis_tag, - label=axis_label, - minimum=float(axis.minValue), - default=float(axis.defaultValue), - maximum=float(axis.maxValue), - hidden=bool(getattr(axis, "flags", 0) & 0x0001), - ) - ) - for index, instance in enumerate(font["fvar"].instances): - instance_name = _get_name_table_value(font, (int(instance.subfamilyNameID),)) or ( - f"Instance {index + 1}" - ) - coordinates = { - axis.axisTag: float(instance.coordinates.get(axis.axisTag, axis.defaultValue)) - for axis in font["fvar"].axes - } - instances.append(FontInstance(name=instance_name, coordinates=coordinates)) - except FontValidationError: - raise - except Exception as e: - raise FontValidationError("The uploaded file is not a valid font") from e - - return FontValidationResult( - filename=filename, - family=family, - label=label, - style=style, - weight=weight, - content_hash=hashlib.sha256(data).hexdigest(), - byte_size=len(data), - axes=tuple(axes), - instances=tuple(instances), - ) - - -def _parse_font_worker(filename: str, data: bytes, max_bytes: int) -> FontValidationResult: - """Pickle-friendly worker entry point for isolated metadata parsing.""" - return _parse_font_bytes(filename, data, max_bytes=max_bytes) - - def _validation_from_record(record: FontRecord, filename: str) -> FontValidationResult: """Reuse indexed metadata when a directory file's content hash is unchanged.""" return FontValidationResult( @@ -854,34 +671,4 @@ def _path_has_symlink_component(path: Path, boundary: Path) -> bool: current = current.parent -def _instantiate_font_bytes(data: bytes, filename: str, coordinates: dict[str, float]) -> bytes: - """Worker function for variable-font instancing.""" - source = io.BytesIO(data) - with TTFont(file=source, lazy=False) as font: - if "fvar" not in font: - return data - flavor = font.flavor - # fontTools calls the coordinate mapping ``axisLimits``: values outside the - # declared ranges are rejected by the service before this worker is submitted. - instantiated = instantiateVariableFont(font, axisLimits=coordinates, inplace=False) - instantiated.flavor = flavor - output = io.BytesIO() - try: - instantiated.save(output) - return output.getvalue() - finally: - instantiated.close() - - -__all__ = [ - "FontChangedError", - "FontDeleteForbiddenError", - "FontForbiddenError", - "FontInstanceError", - "FontNotFoundError", - "FontQuotaExceededError", - "FontService", - "FontStorageError", - "FontValidationError", - "SUPPORTED_FONT_EXTENSIONS", -] +__all__ = ["FontService"] diff --git a/invokeai/app/services/fonts/fonts_parsing.py b/invokeai/app/services/fonts/fonts_parsing.py new file mode 100644 index 00000000000..7b01d4c2b83 --- /dev/null +++ b/invokeai/app/services/fonts/fonts_parsing.py @@ -0,0 +1,188 @@ +"""Byte-level fontTools work run by the font worker subprocess. + +This module imports neither the database layer nor the application config, so a worker +spawned for each parse or instance pays only for fontTools. +""" + +from __future__ import annotations + +import hashlib +import io +import re +from pathlib import Path +from typing import Any + +from fontTools.ttLib import TTFont +from fontTools.varLib.instancer import instantiateVariableFont + +from invokeai.app.services.fonts.fonts_common import ( + SUPPORTED_FONT_EXTENSIONS, + FontAxis, + FontInstance, + FontValidationError, + FontValidationResult, +) + + +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, filename: 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} {filename}") + 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, filename: str, italic_flag: bool) -> str: + if italic_flag: + return "italic" + combined = _normalize_variant_text(f"{style_name} {filename}") + return "italic" if "italic" in combined or "oblique" in combined else "normal" + + +def _get_name_table_value(font: TTFont, name_ids: tuple[int, ...]) -> str | None: + """Read a localized OpenType name, preferring English Windows records.""" + if "name" not in font: + return None + + def sort_key(record: Any) -> tuple[int, int]: + platform_id = getattr(record, "platformID", -1) + language_id = getattr(record, "langID", -1) + if platform_id == 3 and language_id in (0x0409, 0): + return (0, 0) + if platform_id == 3: + return (1, 0) + if platform_id == 0: + return (2, 0) + return (3, language_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 parse_font_bytes(filename: str, data: bytes, *, max_bytes: int) -> FontValidationResult: + """Validate and extract metadata without persisting the supplied bytes.""" + if ( + not filename + or Path(filename).name != filename + or any(separator in filename for separator in ("/", "\\")) + or any(ord(character) < 32 for character in filename) + or filename in {".", ".."} + ): + raise FontValidationError("Font filename is invalid") + extension = Path(filename).suffix.lower() + if extension not in SUPPORTED_FONT_EXTENSIONS: + raise FontValidationError("Unsupported font format; use TTF, OTF, WOFF, or WOFF2") + if len(data) == 0: + raise FontValidationError("Font file is empty") + if len(data) > max_bytes: + raise FontValidationError(f"Font file exceeds the {max_bytes // (1024 * 1024)} MiB limit") + + try: + with TTFont(file=io.BytesIO(data), lazy=False) as font: + family = (_get_name_table_value(font, (16, 1, 4)) or "").strip() + style_name = (_get_name_table_value(font, (17, 2)) or "").strip() + label = (_get_name_table_value(font, (4,)) or family).strip() + if not family: + raise FontValidationError("Font is missing a family name") + + 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)) + weight = _infer_font_weight(style_name, filename, weight_class) + style = _infer_font_style(style_name, filename, italic_flag) + + axes: list[FontAxis] = [] + instances: list[FontInstance] = [] + if "fvar" in font: + for axis in font["fvar"].axes: + axis_tag = str(axis.axisTag) + axis_label = _get_name_table_value(font, (int(axis.axisNameID),)) or axis_tag + axes.append( + FontAxis( + tag=axis_tag, + label=axis_label, + minimum=float(axis.minValue), + default=float(axis.defaultValue), + maximum=float(axis.maxValue), + hidden=bool(getattr(axis, "flags", 0) & 0x0001), + ) + ) + for index, instance in enumerate(font["fvar"].instances): + instance_name = _get_name_table_value(font, (int(instance.subfamilyNameID),)) or ( + f"Instance {index + 1}" + ) + coordinates = { + axis.axisTag: float(instance.coordinates.get(axis.axisTag, axis.defaultValue)) + for axis in font["fvar"].axes + } + instances.append(FontInstance(name=instance_name, coordinates=coordinates)) + except FontValidationError: + raise + except Exception as e: + raise FontValidationError("The uploaded file is not a valid font") from e + + return FontValidationResult( + filename=filename, + family=family, + label=label, + style=style, + weight=weight, + content_hash=hashlib.sha256(data).hexdigest(), + byte_size=len(data), + axes=tuple(axes), + instances=tuple(instances), + ) + + +def instantiate_font_bytes(data: bytes, filename: str, coordinates: dict[str, float]) -> bytes: + """Worker function for variable-font instancing.""" + source = io.BytesIO(data) + with TTFont(file=source, lazy=False) as font: + if "fvar" not in font: + return data + flavor = font.flavor + # fontTools calls the coordinate mapping ``axisLimits``: values outside the + # declared ranges are rejected by the service before this worker is submitted. + instantiated = instantiateVariableFont(font, axisLimits=coordinates, inplace=False) + instantiated.flavor = flavor + output = io.BytesIO() + try: + instantiated.save(output) + return output.getvalue() + finally: + instantiated.close() diff --git a/invokeai/app/services/fonts/fonts_worker.py b/invokeai/app/services/fonts/fonts_worker.py index 7d653ae0a2e..af466c0667e 100644 --- a/invokeai/app/services/fonts/fonts_worker.py +++ b/invokeai/app/services/fonts/fonts_worker.py @@ -1,8 +1,8 @@ """Small subprocess entry point for bounded fontTools work. The API process sends one font as stdin and receives a pickled result on stdout. Keeping -this module separate from the web application avoids importing the inference stack in a -worker and lets the service terminate a job that exceeds its deadline. +this module separate from the web application avoids importing the database layer, config, +and inference stack in a worker and lets the service terminate a job that exceeds its deadline. """ from __future__ import annotations @@ -11,7 +11,7 @@ import pickle import sys -from invokeai.app.services.fonts.fonts_default import _instantiate_font_bytes, _parse_font_bytes +from invokeai.app.services.fonts.fonts_parsing import instantiate_font_bytes, parse_font_bytes def main() -> None: @@ -23,9 +23,9 @@ def main() -> None: data = sys.stdin.buffer.read() try: if operation == "parse" and len(sys.argv) == 4: - result = _parse_font_bytes(filename, data, max_bytes=int(sys.argv[3])) + result = parse_font_bytes(filename, data, max_bytes=int(sys.argv[3])) elif operation == "instance" and len(sys.argv) == 4: - result = _instantiate_font_bytes(data, filename, json.loads(sys.argv[3])) + result = instantiate_font_bytes(data, filename, json.loads(sys.argv[3])) else: raise ValueError("invalid font worker operation") except BaseException as error: diff --git a/tests/app/api/test_video_upload_limits.py b/tests/app/api/test_video_upload_limits.py index 17d5b0d90be..3cf0259e3c7 100644 --- a/tests/app/api/test_video_upload_limits.py +++ b/tests/app/api/test_video_upload_limits.py @@ -10,7 +10,6 @@ import asyncio import gc import tempfile -import time import tracemalloc from collections.abc import Iterator from contextlib import contextmanager @@ -480,21 +479,24 @@ async def test_upload_authentication_does_not_block_the_event_loop(monkeypatch: set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production") token = create_access_token(TokenData(user_id="user", email="user@example.com", is_admin=False)) offloaded: list[Any] = [] + # Holds the offloaded call open until the heartbeat has been observed, so the ordering below does + # not depend on how fast the loop runs. + release = asyncio.Event() async def run_in_threadpool(func: Any, *args: Any) -> Any: offloaded.append(func) - await asyncio.sleep(0.1) + await release.wait() return func(*args) monkeypatch.setattr(api_app, "run_in_threadpool", run_in_threadpool) - def slow_get(_user_id: str) -> SimpleNamespace: + def get_active_user(_user_id: str) -> SimpleNamespace: return SimpleNamespace(user_id="user", is_active=True, token_epoch=0) invoker = SimpleNamespace( services=SimpleNamespace( configuration=SimpleNamespace(multiuser=True), - users=SimpleNamespace(get=slow_get), + users=SimpleNamespace(get=get_active_user), ) ) monkeypatch.setattr(api_app.ApiDependencies, "invoker", invoker, raising=False) @@ -523,14 +525,17 @@ async def receive() -> dict[str, Any]: async def send(_message: dict[str, Any]) -> None: pass - started = time.monotonic() upload = asyncio.create_task(middleware(scope, receive, send)) # type: ignore[arg-type] - await asyncio.sleep(0.01) - heartbeat_elapsed = time.monotonic() - started - await upload + try: + await asyncio.sleep(0.01) - assert heartbeat_elapsed < 0.05 - assert offloaded == [_identify_video_upload_user] + # Authentication run on the loop instead of the threadpool leaves `offloaded` empty. + assert offloaded == [_identify_video_upload_user] + # A request that completes without waiting for the offloaded authentication is done here. + assert not upload.done() + finally: + release.set() + await upload BOUNDARY = "testboundary" diff --git a/tests/app/invocations/test_video_concat_splice.py b/tests/app/invocations/test_video_concat_splice.py index 2bb2a80f37e..69a96848a60 100644 --- a/tests/app/invocations/test_video_concat_splice.py +++ b/tests/app/invocations/test_video_concat_splice.py @@ -27,10 +27,10 @@ from invokeai.app.invocations.video_concat import VideoConcatInvocation from invokeai.app.services.session_processor.session_processor_common import CanceledException from invokeai.app.util.aac_splice import PREROLL_FRAMES -from invokeai.app.util.h264_splice import read_h264_layout +from invokeai.app.util.h264_splice import MIN_ENCODED_FRAMES, read_h264_layout from invokeai.app.util.mp4_metadata import find_box, write_mp4_tags from invokeai.app.util.video_audio import extract_audio_pcm -from invokeai.app.util.video_encoding import make_mp4_writer, write_stereo_wav +from invokeai.app.util.video_encoding import KEYFRAME_INTERVAL_SECONDS, make_mp4_writer, write_stereo_wav from invokeai.app.util.video_thumbnails import probe_video FPS = 16.0 @@ -156,21 +156,6 @@ def test_first_clip_head_is_copied_bit_exact(tmp_path, transition, transition_fr assert saved["duration"] == pytest.approx(expected_len / FPS) -def test_repeated_extension_leaves_the_original_segment_untouched(tmp_path): - a = _encode(tmp_path / "a.mp4", _frames(100, seed=1)) - original = _decode(a) - current = a - for round_ in range(3): - nxt = _encode(tmp_path / f"n{round_}.mp4", _frames(40, seed=10 + round_)) - current, _ = _concat(tmp_path, [current, nxt], f"round{round_}", transition="crossfade", transition_frames=2) - final = _decode(current) - assert len(final) == 100 + 3 * (40 - 2) - # Each round re-encodes only from the last keyframe of its first clip, which lies in the - # newest continuation from the second round on: A's own head is copied every time. - keyframe = _last_usable_keyframe(a, 2) - assert all(np.array_equal(x, y) for x, y in zip(original[:keyframe], final[:keyframe], strict=True)) - - def test_spliced_join_keeps_the_audio_timeline(tmp_path): a = _encode(tmp_path / "a.mp4", _frames(64, seed=1), tone=440.0) b = _encode(tmp_path / "b.mp4", _frames(64, seed=2), tone=880.0) @@ -255,10 +240,18 @@ def test_short_remainder_keeps_one_timeline(tmp_path): """A remainder of 1-2 frames would be encoded with a shorter B-frame delay than the copied head, distorting the joined timestamps.""" b = _encode(tmp_path / "b.mp4", _frames(1, seed=2)) - # Every first-clip length just past a keyframe, so one leaves a 1-2 frame remainder. - for n_a in range(65, 70): + interval = round(FPS * KEYFRAME_INTERVAL_SECONDS) + keyframe = 2 * interval + # First clips ending 1 and 2 frames past a keyframe leave a remainder too short to encode + # there, so the splice falls back to the keyframe before; the next length is the first that + # splices at it. + for n_a in range(keyframe + 1, keyframe + MIN_ENCODED_FRAMES + 1): a = _encode(tmp_path / f"a{n_a}.mp4", _frames(n_a, seed=1)) - out, _ = _concat(tmp_path, [a, b], f"out{n_a}", transition="cut", transition_frames=0) + layout = read_h264_layout(a) + assert layout is not None and layout.sync_samples == (0, interval, keyframe), f"first clip of {n_a} frames" + out, context = _concat(tmp_path, [a, b], f"out{n_a}", transition="cut", transition_frames=0) + copied = keyframe if n_a - keyframe >= MIN_ENCODED_FRAMES else interval + assert f"Stream-copied the first {copied} frames" in _logged(context), f"first clip of {n_a} frames" _, _, duration, fps = probe_video(out) assert fps == pytest.approx(FPS, rel=1e-3), f"first clip of {n_a} frames" assert duration == pytest.approx((n_a + 1) / FPS, abs=0.5 / FPS) @@ -290,10 +283,11 @@ def _save(source_path: Path, **_: object) -> MagicMock: assert len(_decode(out)) == 36 + 40 - 2 -def test_repeated_extension_leaves_the_original_soundtrack_untouched(tmp_path): - """Every join used to decode and re-encode the whole soundtrack, compounding AAC loss - in the earliest audio just as in its video.""" +def test_repeated_extension_leaves_the_original_segment_and_soundtrack_untouched(tmp_path): + """Every join used to re-encode the whole first clip, video and soundtrack, compounding + lossy generations in the earliest footage and audio.""" a = _encode(tmp_path / "a.mp4", _frames(100, seed=1), tone=440.0) + original_frames = _decode(a) original = extract_audio_pcm(a) assert original is not None current = a @@ -309,6 +303,13 @@ def test_repeated_extension_leaves_the_original_soundtrack_untouched(tmp_path): assert decoded is not None assert 0 <= decoded[0].shape[1] - round(frames * RATE / FPS) < 1024 + final_frames = _decode(current) + assert len(final_frames) == 100 + 3 * (40 - 2) + # Each round re-encodes only from the last keyframe of its first clip, which lies in the + # newest continuation from the second round on: A's own head is copied every time. + keyframe = _last_usable_keyframe(a, 2) + assert all(np.array_equal(x, y) for x, y in zip(original_frames[:keyframe], final_frames[:keyframe], strict=True)) + final = extract_audio_pcm(current) assert final is not None and final[1] == original[1] == RATE # A's audio is copied every round up to a whole AAC frame at least PREROLL_FRAMES clear of diff --git a/tests/app/services/fonts/test_fonts_service.py b/tests/app/services/fonts/test_fonts_service.py index be2513bf6f5..ddf0bdc7011 100644 --- a/tests/app/services/fonts/test_fonts_service.py +++ b/tests/app/services/fonts/test_fonts_service.py @@ -3,6 +3,7 @@ import io import logging import subprocess +import sys import threading from pathlib import Path @@ -13,16 +14,17 @@ from sqlalchemy import select from invokeai.app.services.fonts import fonts_default -from invokeai.app.services.fonts.fonts_common import FontScope, FontSource, FontUploadResult -from invokeai.app.services.fonts.fonts_default import ( - MAX_INDEXED_FONT_BYTES, +from invokeai.app.services.fonts.fonts_common import ( FontChangedError, FontDeleteForbiddenError, FontNotFoundError, FontQuotaExceededError, - FontService, + FontScope, + FontSource, + FontUploadResult, FontValidationError, ) +from invokeai.app.services.fonts.fonts_default import MAX_INDEXED_FONT_BYTES, FontService from invokeai.app.services.shared.database.database import Database from invokeai.app.services.shared.database.queries import fonts as font_queries from invokeai.app.services.shared.database.queries.fonts import FontQueries, UploadedFont @@ -672,3 +674,16 @@ def test_rescans_run_one_at_a_time( assert font_service.rescan_directory() == 1 assert ended() == [] assert font_service.list(user_id="system")[1] == 1 + + +@pytest.mark.platform_independent +def test_font_worker_imports_neither_the_database_layer_nor_config() -> None: + # Every font parse and instance spawns this worker; the database layer alone costs it most of a second. + loaded = subprocess.run( + [sys.executable, "-c", "import sys, invokeai.app.services.fonts.fonts_worker; print(*sys.modules)"], + capture_output=True, + text=True, + check=True, + ).stdout.split() + heavy = ("invokeai.app.services.shared.database", "invokeai.app.services.config") + assert [module for module in loaded if module.startswith(heavy)] == [] diff --git a/tests/app/services/image_index/test_umap_import.py b/tests/app/services/image_index/test_umap_import.py index ab109857baf..bec72ac8358 100644 --- a/tests/app/services/image_index/test_umap_import.py +++ b/tests/app/services/image_index/test_umap_import.py @@ -1,23 +1,20 @@ -"""Smoke test for the umap-learn / scikit-learn / numba dependency stack. +"""Guard for the numba JIT under the umap-learn / scikit-learn dependency stack. The numba/llvmlite install matrix is the most fragile part of these dependencies (it is -sensitive to the numpy pin), so this test exists to fail loudly in CI if resolution or the JIT -is broken, before any feature code relies on them. - -Not marked `slow`: measured at ~10s cold (numba JIT cache empty) and ~7s warm, which the -default selection can absorb. +sensitive to the numpy pin). A broken resolution fails the real UMAP fits and DBSCAN runs in +test_projection.py; this file catches the failure those fits cannot see: an install that +silently lost compilation. """ import numba -import numpy as np def test_numba_jit_is_active() -> None: """A working import is not a working JIT. UMAP produces correct output with the JIT disabled — numba just runs the kernels as plain - Python — so the fit below cannot tell a healthy install from one that silently lost - compilation and got orders of magnitude slower. Assert compilation directly. + Python — so the fits in test_projection.py cannot tell a healthy install from one that + silently lost compilation and got orders of magnitude slower. Assert compilation directly. """ assert numba.config.DISABLE_JIT == 0, "numba JIT is disabled; UMAP would fall back to Python speed" @@ -28,19 +25,3 @@ def _add(a: int, b: int) -> int: assert _add(1, 2) == 3 # Non-empty only if the call actually compiled a specialization. assert _add.signatures, "numba did not compile the test kernel" - - -def test_umap_and_dbscan_smoke() -> None: - from sklearn.cluster import DBSCAN - from umap import UMAP - - rng = np.random.default_rng(42) - embeddings = rng.standard_normal((50, 16)).astype(np.float32) - - coords = UMAP(n_neighbors=10, n_components=2, min_dist=0.05, metric="cosine").fit_transform(embeddings) - coords = np.asarray(coords) - assert coords.shape == (50, 2) - assert np.isfinite(coords).all() - - labels = DBSCAN(eps=0.5, min_samples=5).fit(coords).labels_ - assert labels.shape == (50,) diff --git a/tests/app/services/test_session_processor_shutdown.py b/tests/app/services/test_session_processor_shutdown.py index 6d2d2879a41..ee50e63ef31 100644 --- a/tests/app/services/test_session_processor_shutdown.py +++ b/tests/app/services/test_session_processor_shutdown.py @@ -1,3 +1,7 @@ +import sys + +import pytest + from tests.app.services import workflow_call_test_utils as workflow_call_tests @@ -5,6 +9,13 @@ def test_run_node_propagates_keyboard_interrupt(monkeypatch) -> None: workflow_call_tests.test_run_node_propagates_keyboard_interrupt(monkeypatch) +@pytest.mark.skipif( + sys.platform == "win32", + reason=( + "On Windows os.kill(pid, SIGINT) ends the process via TerminateProcess instead of raising KeyboardInterrupt, " + "so the test passes even when the runner swallows interrupts." + ), +) def test_run_node_does_not_swallow_sigint_in_subprocess() -> None: workflow_call_tests.test_run_node_does_not_swallow_sigint_in_subprocess() diff --git a/tests/backend/stable_diffusion/test_vae_tiling.py b/tests/backend/stable_diffusion/test_vae_tiling.py index edd0f67a2a4..097bb2e0a9e 100644 --- a/tests/backend/stable_diffusion/test_vae_tiling.py +++ b/tests/backend/stable_diffusion/test_vae_tiling.py @@ -49,6 +49,7 @@ def tiny_vae() -> AutoencoderTiny: class TestEveryFieldValueProducesTheRightShape: + @pytest.mark.platform_independent def test_tiled_decode_keeps_the_full_image_size(self, vae): """Before the derivation was shared, 36 of these 49 values assembled an image between 518 and 542 pixels wide instead of 512 -- silently, and on the most-used decode path.""" @@ -64,6 +65,7 @@ def test_tiled_decode_keeps_the_full_image_size(self, vae): assert not wrong + @pytest.mark.platform_independent def test_tiled_encode_keeps_the_full_latent_size(self, vae): """The encode path is affected too, and not symmetrically: `_tiled_encode` steps a *pixel* loop and crops a *latent* tile, so the same inconsistency shows up as a latent one or two @@ -97,6 +99,7 @@ class TestTheTinyVaeIsUnaffected: `tile_latent_min_size` in its tiled decode, so the two never meet and it had no bug to fix. It goes through the same derivation rather than a class branch; this pins that that is harmless.""" + @pytest.mark.platform_independent def test_tiled_decode_and_encode_keep_their_shapes(self, tiny_vae): tiny_vae.enable_tiling() for tile_size in FIELD_VALUES: