From 154a1f25f537c19180ce4abfdcf3163db7c57956 Mon Sep 17 00:00:00 2001 From: Ding Zuhao Date: Mon, 21 Sep 2026 15:29:24 +0800 Subject: [PATCH 01/21] [Bugfix] Honor the requested output canvas in BAGEL img2img (#7287) Signed-off-by: Ding Zuhao Co-authored-by: Cursor --- .../examples/online_serving/bagel.md | 22 +- recipes/Bagel/BAGEL-7B-MoT.md | 19 +- .../models/bagel/test_step_execution.py | 189 +++++++++++++++++- tests/e2e/online_serving/test_bagel.py | 54 +++++ .../openai_api/test_image_server.py | 37 +++- .../diffusion/models/bagel/pipeline_bagel.py | 79 +++++++- vllm_omni/entrypoints/openai/api_server.py | 10 +- .../entrypoints/openai/images/helpers.py | 8 + 8 files changed, 391 insertions(+), 27 deletions(-) diff --git a/docs/user_guide/examples/online_serving/bagel.md b/docs/user_guide/examples/online_serving/bagel.md index 6c10c4349..6b1e20391 100644 --- a/docs/user_guide/examples/online_serving/bagel.md +++ b/docs/user_guide/examples/online_serving/bagel.md @@ -247,12 +247,18 @@ curl http://localhost:8091/v1/chat/completions \ -d @payload.json ``` -BAGEL img2img derives its generated image size from the input image. It -preserves the input aspect ratio, aligns dimensions to the latent stride, and -applies the checkpoint size limit; explicit `height` and `width` values do not -override this model-specific resize policy. In step mode, the effective size is -resolved before scheduler admission so requests with different resulting -shapes are not combined. +When no output size is requested (`size=auto` on `/v1/images/edits`, or no +`height`/`width` in a chat request), BAGEL img2img derives the canvas from the +input image: it preserves the input aspect ratio, aligns dimensions to the +latent stride (16 px), and applies the checkpoint size limit. An explicit +`size`, `height`, or `width` is honored instead, in both the single-stage and +the two-stage deployment; the input image is still resized to a stride-aligned +size for the VAE/ViT prefill. The latent grid needs multiples of 16, so a +requested side that is not one is floored to the next multiple (1000x700 +generates 992x688) and the server logs a warning; the response reports the +generated size. A side above the checkpoint limit is rejected with HTTP 400. +In step mode, the effective size is resolved before scheduler admission so +requests with different resulting shapes are not combined. ### Image to Text (img2text) @@ -318,8 +324,8 @@ curl http://localhost:8091/v1/chat/completions \ | `--server` / `-s` | `http://localhost:8091` | Server URL | | `--image-url` / `-i` | `None` | Input image URL or local path (img2img/img2text) | | `--modality` / `-m` | `text2img` | `text2img`, `img2img`, `img2text`, `text2text` | -| `--height` | `512` | Text-to-image output height; BAGEL img2img derives its size from the input image | -| `--width` | `512` | Text-to-image output width; BAGEL img2img derives its size from the input image | +| `--height` | `512` | Output height, a multiple of 16; omit both `--height` and `--width` to let img2img derive the size from the input image | +| `--width` | `512` | Output width, a multiple of 16; omit both `--height` and `--width` to let img2img derive the size from the input image | | `--steps` | `25` | Number of inference steps | | `--seed` | `42` | Random seed | | `--negative` | `None` | Negative prompt for CFG | diff --git a/recipes/Bagel/BAGEL-7B-MoT.md b/recipes/Bagel/BAGEL-7B-MoT.md index 19ace3d33..17e10fb69 100644 --- a/recipes/Bagel/BAGEL-7B-MoT.md +++ b/recipes/Bagel/BAGEL-7B-MoT.md @@ -120,12 +120,19 @@ python examples/offline_inference/image_to_image/image_edit.py \ The `--extra-args` JSON forwards BAGEL-specific parameters (e.g. `cfg_text_scale`, `cfg_img_scale`, `cfg_interval`, `cfg_renorm_type`) into `OmniDiffusionSamplingParams.extra_args` via the model-extras registry. -For BAGEL img2img, the pipeline derives the generated image size from the input -image: it preserves the input aspect ratio, aligns dimensions to the latent -stride, and applies the checkpoint size limit. Explicit `--height` and -`--width` values do not override that BAGEL-specific resize policy. Step-mode -admission resolves this effective size before batching so differently shaped -requests are scheduled separately. +For BAGEL img2img, an explicit `--height`/`--width` (or `size` on +`/v1/images/edits`) sets the output canvas, in both the single-stage and the +two-stage deployment; the input image is still resized to a stride-aligned size +for the VAE/ViT prefill. The latent grid needs multiples of 16, so a requested +side that is not one is floored to the next multiple (1000x700 generates +992x688) with a server-side warning, and the response reports the generated +size. A side above the checkpoint limit is rejected with HTTP 400. When no size +is requested +(`size=auto`, or no `height`/`width`), the pipeline derives the canvas from the +input image: it preserves the input aspect ratio, aligns dimensions to the +latent stride, and applies the checkpoint size limit. Step-mode admission +resolves this effective size before batching so differently shaped requests are +scheduled separately. Run text-to-text with the shared understanding example. BAGEL's default `bagel.yaml` deploy config is discovered from the checkpoint, so no model-specific diff --git a/tests/diffusion/models/bagel/test_step_execution.py b/tests/diffusion/models/bagel/test_step_execution.py index 40d4154e3..e5f8e5d5a 100644 --- a/tests/diffusion/models/bagel/test_step_execution.py +++ b/tests/diffusion/models/bagel/test_step_execution.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Focused CPU tests for BAGEL step execution and packed batching.""" from __future__ import annotations @@ -18,12 +18,14 @@ from vllm_omni.diffusion.models.bagel.bagel_transformer import Bagel, NaiveCache from vllm_omni.diffusion.models.bagel.pipeline_bagel import ( BagelGenParams, BagelPipeline, + _bagel_canvas_requested, get_bagel_pre_process_func, ) from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.sched import StepScheduler from vllm_omni.diffusion.worker.input_batch import InputBatch from vllm_omni.diffusion.worker.utils import StepRequestState +from vllm_omni.errors import OmniClientError from vllm_omni.inputs.data import OmniDiffusionSamplingParams pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -118,7 +120,7 @@ def test_bagel_step_preprocessor_separates_incompatible_cfg_settings(first_extra assert first.batch_compatibility_key != second.batch_compatibility_key -def test_bagel_step_preprocessor_buckets_effective_img2img_sizes(tmp_path): +def _pre_process_with_stub_checkpoint(tmp_path): (tmp_path / "config.json").write_text( '{"vae_config":{"downsample":8},"latent_patch_size":2,"max_latent_size":32}', encoding="utf-8", @@ -131,9 +133,22 @@ def test_bagel_step_preprocessor_buckets_effective_img2img_sizes(tmp_path): '{"weight_map":{"latent_pos_embed.pos_embed":"ema.safetensors"}}', encoding="utf-8", ) - pre_process = get_bagel_pre_process_func( - types.SimpleNamespace(model=str(tmp_path), revision=None, step_execution=True) + return get_bagel_pre_process_func(types.SimpleNamespace(model=str(tmp_path), revision=None, step_execution=True)) + + +def _edit_request(source_size, sampling_params, request_id="req"): + return OmniDiffusionRequest( + prompt={ + "prompt": "edit this image", + "multi_modal_data": {"image": [Image.new("RGB", source_size)]}, + }, + sampling_params=sampling_params, + request_id=request_id, ) + + +def test_bagel_step_preprocessor_buckets_effective_img2img_sizes(tmp_path): + pre_process = _pre_process_with_stub_checkpoint(tmp_path) requests = [ OmniDiffusionRequest( prompt={ @@ -163,6 +178,172 @@ def test_bagel_step_preprocessor_buckets_effective_img2img_sizes(tmp_path): assert schedule.num_waiting_reqs == 1 +def test_bagel_pre_process_keeps_explicitly_requested_canvas(tmp_path): + pre_process = _pre_process_with_stub_checkpoint(tmp_path) + request = _edit_request((512, 1024), OmniDiffusionSamplingParams(num_inference_steps=2, height=1024, width=1024)) + + pre_process(request) + + assert (request.sampling_params.height, request.sampling_params.width) == (1024, 1024) + assert _bagel_canvas_requested(request.sampling_params) + + +def test_bagel_pre_process_aligns_source_derived_canvas_for_size_auto(tmp_path): + pre_process = _pre_process_with_stub_checkpoint(tmp_path) + request = _edit_request( + (500, 700), + OmniDiffusionSamplingParams( + num_inference_steps=2, + height=700, + width=500, + height_not_provided=True, + width_not_provided=True, + ), + ) + + pre_process(request) + + assert (request.sampling_params.height, request.sampling_params.width) == (704, 496) + assert not _bagel_canvas_requested(request.sampling_params) + + +def test_bagel_pre_process_derives_canvas_when_caller_asked_for_none(tmp_path): + pre_process = _pre_process_with_stub_checkpoint(tmp_path) + request = _edit_request((800, 400), OmniDiffusionSamplingParams(num_inference_steps=2)) + + pre_process(request) + + assert (request.sampling_params.height, request.sampling_params.width) == (400, 800) + assert (request.sampling_params.height_not_provided, request.sampling_params.width_not_provided) == (True, True) + assert not _bagel_canvas_requested(request.sampling_params) + + +def test_bagel_pre_process_marks_kv_metadata_canvas_as_requested(tmp_path): + pre_process = _pre_process_with_stub_checkpoint(tmp_path) + request = _edit_request( + (500, 700), + OmniDiffusionSamplingParams( + num_inference_steps=2, + height=700, + width=500, + height_not_provided=True, + width_not_provided=True, + kv_metadata={"image_shape": (1024, 1024)}, + ), + ) + + pre_process(request) + + assert (request.sampling_params.height, request.sampling_params.width) == (1024, 1024) + assert (request.sampling_params.height_not_provided, request.sampling_params.width_not_provided) == (False, False) + assert _bagel_canvas_requested(request.sampling_params) + + +def test_bagel_pre_process_prefers_explicit_canvas_over_kv_metadata(tmp_path): + pre_process = _pre_process_with_stub_checkpoint(tmp_path) + request = _edit_request( + (1280, 720), + OmniDiffusionSamplingParams( + num_inference_steps=2, + height=1024, + width=1024, + kv_metadata={"image_shape": (576, 1024)}, + ), + ) + + pre_process(request) + + assert (request.sampling_params.height, request.sampling_params.width) == (1024, 1024) + assert _bagel_canvas_requested(request.sampling_params) + + +def test_bagel_pre_process_floors_unaligned_explicit_canvas_with_warning(tmp_path): + pre_process = _pre_process_with_stub_checkpoint(tmp_path) + request = _edit_request( + (1280, 720), + OmniDiffusionSamplingParams(num_inference_steps=2, height=700, width=1000), + ) + + with patch("vllm_omni.diffusion.models.bagel.pipeline_bagel.logger.warning") as warning: + pre_process(request) + + assert (request.sampling_params.height, request.sampling_params.width) == (688, 992) + assert _bagel_canvas_requested(request.sampling_params) + warning.assert_called_once() + assert "width 1000->992, height 700->688" in warning.call_args.args[-1] + + +def test_bagel_pre_process_keeps_aligned_explicit_canvas_silent(tmp_path): + pre_process = _pre_process_with_stub_checkpoint(tmp_path) + request = _edit_request( + (1280, 720), + OmniDiffusionSamplingParams(num_inference_steps=2, height=512, width=1024), + ) + + with patch("vllm_omni.diffusion.models.bagel.pipeline_bagel.logger.warning") as warning: + pre_process(request) + + assert (request.sampling_params.height, request.sampling_params.width) == (512, 1024) + warning.assert_not_called() + + +def test_bagel_pre_process_rejects_explicit_canvas_over_checkpoint_limit(tmp_path): + pre_process = _pre_process_with_stub_checkpoint(tmp_path) + request = _edit_request( + (1280, 720), + OmniDiffusionSamplingParams(num_inference_steps=2, height=512, width=2048), + ) + + with pytest.raises(OmniClientError, match="width=2048 exceeds the BAGEL checkpoint limit of 1024") as excinfo: + pre_process(request) + + assert excinfo.value.status_code == 400 + + +def test_bagel_pre_process_aligns_text2img_canvas_too(tmp_path): + pre_process = _pre_process_with_stub_checkpoint(tmp_path) + request = OmniDiffusionRequest( + prompt="a cat", + sampling_params=OmniDiffusionSamplingParams(num_inference_steps=2, height=700, width=1000), + request_id="req", + ) + + pre_process(request) + + assert (request.sampling_params.height, request.sampling_params.width) == (688, 992) + + +def test_bagel_pre_process_skips_canvas_alignment_for_text_output(tmp_path): + pre_process = _pre_process_with_stub_checkpoint(tmp_path) + request = OmniDiffusionRequest( + prompt={ + "prompt": "describe this image", + "modalities": ["text"], + "multi_modal_data": {"image": [Image.new("RGB", (1280, 720))]}, + }, + sampling_params=OmniDiffusionSamplingParams(num_inference_steps=2, height=700, width=1000), + request_id="req", + ) + + pre_process(request) + + assert (request.sampling_params.height, request.sampling_params.width) == (700, 1000) + assert request.use_step_execution is False + + +@pytest.mark.parametrize( + ("sampling_kwargs", "requested"), + [ + ({"height": 1024, "width": 1024}, True), + ({"height": 1024, "width": 512, "height_not_provided": True, "width_not_provided": True}, False), + ({}, False), + ], + ids=["explicit_size", "size_auto", "unset"], +) +def test_bagel_canvas_requested_separates_asked_for_from_derived(sampling_kwargs, requested): + assert _bagel_canvas_requested(OmniDiffusionSamplingParams(**sampling_kwargs)) is requested + + def _cache(length: int, value: float, num_layers: int = 1) -> NaiveCache: cache = NaiveCache(num_layers) if length: diff --git a/tests/e2e/online_serving/test_bagel.py b/tests/e2e/online_serving/test_bagel.py index 84c2302cc..1a11dc037 100644 --- a/tests/e2e/online_serving/test_bagel.py +++ b/tests/e2e/online_serving/test_bagel.py @@ -25,9 +25,11 @@ import os from io import BytesIO import pytest +from PIL import Image from vllm.assets.image import ImageAsset from tests.helpers.mark import hardware_test +from tests.helpers.media import generate_synthetic_image from tests.helpers.runtime import OmniServerParams from tests.helpers.stage_config import get_deploy_config_path @@ -120,3 +122,55 @@ def test_bagel_img2img_online(omni_server, online_client) -> None: } online_client.send_diffusion_request(request_config) + + +EDIT_SOURCE_SIZE = (1280, 720) +EDIT_REQUESTED_SIZE = "1024x1024" + + +def _edit_source_jpeg() -> tuple[str, bytes, str]: + synthetic = generate_synthetic_image(*EDIT_SOURCE_SIZE, seed=7287) + return ("source.jpg", base64.b64decode(synthetic["base64"]), "image/jpeg") + + +def _build_edit_request(size: str) -> dict: + return { + "files": [("image", _edit_source_jpeg())], + "data": { + "prompt": IMG2IMG_PROMPT, + "size": size, + "num_inference_steps": 2, + "seed": 42, + "response_format": "b64_json", + }, + } + + +@pytest.mark.slow +@pytest.mark.diffusion +@hardware_test(res={"cuda": "H100"}) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_bagel_image_edit_honors_explicit_size_two_stage(omni_server, online_client) -> None: + """An explicit ``size`` wins over the AR stage's source-derived KV image shape (#7283).""" + (response,) = online_client.send_images_edits_http_request(_build_edit_request(EDIT_REQUESTED_SIZE)) + + payload = response.json_body + assert isinstance(payload, dict) + assert payload["size"] == EDIT_REQUESTED_SIZE + image = Image.open(BytesIO(base64.b64decode(payload["data"][0]["b64_json"]))) + assert image.size == (1024, 1024) + + +@pytest.mark.slow +@pytest.mark.diffusion +@hardware_test(res={"cuda": "H100"}) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_bagel_image_edit_floors_unaligned_size_two_stage(omni_server, online_client) -> None: + """A side that is not a multiple of the latent stride is floored to one (1000x700 -> 992x688).""" + (response,) = online_client.send_images_edits_http_request(_build_edit_request("1000x700")) + + payload = response.json_body + assert isinstance(payload, dict) + assert payload["size"] == "992x688" + image = Image.open(BytesIO(base64.b64decode(payload["data"][0]["b64_json"]))) + assert image.size == (992, 688) diff --git a/tests/entrypoints/openai_api/test_image_server.py b/tests/entrypoints/openai_api/test_image_server.py index 60850248e..1c124a19b 100644 --- a/tests/entrypoints/openai_api/test_image_server.py +++ b/tests/entrypoints/openai_api/test_image_server.py @@ -1719,7 +1719,7 @@ def test_image_edit_parameter_pass(async_omni_test_client): img = Image.open(io.BytesIO(img_bytes)) assert img.format.lower() == "jpeg" assert data["output_format"] == "jpeg" - assert data["size"] == "16x24" + assert data["size"] == "64x64" def test_image_edit_layers_and_resolution(async_omni_test_client): @@ -1896,6 +1896,7 @@ def test_image_edit_parameter_default_single_stage(test_client): assert captured_sampling_params.width == 24 assert captured_sampling_params.height == 16 + assert (captured_sampling_params.height_not_provided, captured_sampling_params.width_not_provided) == (True, True) assert captured_sampling_params.num_outputs_per_prompt == 1 assert captured_sampling_params.num_inference_steps == 4 assert captured_sampling_params.guidance_scale == 7.5 @@ -1913,6 +1914,40 @@ def test_image_edit_parameter_default_single_stage(test_client): assert response.status_code == 400 +def test_image_edit_explicit_size_marks_canvas_provided_single_stage(test_client): + response = test_client.post( + "/v1/images/edits", + files=[("image", make_test_image_bytes((24, 16)))], + data={"prompt": "hello world.", "size": "16x24"}, + ) + + assert response.status_code == 200 + sampling = test_client.app.state.engine_client.captured_sampling_params_list[0] + assert (sampling.height, sampling.width) == (24, 16) + assert (sampling.height_not_provided, sampling.width_not_provided) == (False, False) + + +def test_image_edit_response_size_reports_generated_image_single_stage(test_client): + response = test_client.post( + "/v1/images/edits", + files=[("image", make_test_image_bytes((24, 16)))], + data={"prompt": "hello world.", "size": "16x24"}, + ) + + assert response.status_code == 200 + assert response.json()["size"] == "64x64" + + +def test_generate_images_response_size_reports_generated_image(test_client): + response = test_client.post( + "/v1/images/generations", + json={"prompt": "a cat", "n": 1, "size": "1024x1024"}, + ) + + assert response.status_code == 200 + assert response.json()["size"] == "64x64" + + def test_image_edit_compression_jpeg(test_client): img_bytes_1 = make_test_image_bytes((16, 16)) # uploadfile with image key diff --git a/vllm_omni/diffusion/models/bagel/pipeline_bagel.py b/vllm_omni/diffusion/models/bagel/pipeline_bagel.py index d805fd3b8..25f31679c 100644 --- a/vllm_omni/diffusion/models/bagel/pipeline_bagel.py +++ b/vllm_omni/diffusion/models/bagel/pipeline_bagel.py @@ -35,6 +35,7 @@ from vllm_omni.diffusion.models.interface import SupportsComponentDiscovery from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch +from vllm_omni.errors import OmniClientError from vllm_omni.model_executor.model_loader.weight_utils import download_weights_from_hf_specific from .autoencoder import AutoEncoder, AutoEncoderParams, DistributedAutoEncoder @@ -184,6 +185,45 @@ def _bagel_effective_image_size( return min(resized_height, max_image_size), min(resized_width, max_image_size) +def _bagel_canvas_requested(sampling: OmniDiffusionSamplingParams) -> bool: + return (sampling.height is not None and not sampling.height_not_provided) or ( + sampling.width is not None and not sampling.width_not_provided + ) + + +def _bagel_align_requested_canvas( + sampling: OmniDiffusionSamplingParams, + *, + latent_downsample: int, + max_latent_size: int, +) -> None: + """Floor an explicitly requested canvas to the latent stride; reject sides over the checkpoint limit.""" + + max_image_size = int(max_latent_size * latent_downsample) + resized: list[str] = [] + for name in ("width", "height"): + value = getattr(sampling, name) + if value is None or getattr(sampling, f"{name}_not_provided"): + continue + value = int(value) + if value > max_image_size: + raise OmniClientError( + f"Requested {name}={value} exceeds the BAGEL checkpoint limit of {max_image_size} " + f"(max_latent_size={max_latent_size}, latent_downsample={latent_downsample})." + ) + aligned = max(latent_downsample, (value // latent_downsample) * latent_downsample) + if aligned != value: + setattr(sampling, name, aligned) + resized.append(f"{name} {value}->{aligned}") + if resized: + logger.warning( + "BAGEL: requested canvas is not a multiple of the latent stride %d and cannot be " + "generated as-is; resizing %s.", + latent_downsample, + ", ".join(resized), + ) + + def get_bagel_pre_process_func(od_config: OmniDiffusionConfig): """Resolve BAGEL execution mode and step-batch compatibility.""" @@ -194,12 +234,23 @@ def get_bagel_pre_process_func(od_config: OmniDiffusionConfig): nonlocal image_geometry sampling = request.sampling_params + modalities = (request.prompt.get("modalities") or []) if isinstance(request.prompt, dict) else [] kv_metadata = getattr(sampling, "kv_metadata", None) or {} image_shape = kv_metadata.get("image_shape") - if image_shape is not None: + if _bagel_canvas_requested(sampling) and "text" not in modalities: + if image_geometry is None: + image_geometry = _resolve_bagel_image_geometry(od_config) + latent_downsample, max_latent_size = image_geometry + _bagel_align_requested_canvas( + sampling, + latent_downsample=latent_downsample, + max_latent_size=max_latent_size, + ) + elif image_shape is not None: sampling.height, sampling.width = (int(value) for value in image_shape) + sampling.height_not_provided = False + sampling.width_not_provided = False elif isinstance(request.prompt, dict): - modalities = request.prompt.get("modalities") or [] multi_modal_data = request.prompt.get("multi_modal_data") or {} image_input = multi_modal_data.get("img2img") if image_input is None and "text" not in modalities: @@ -220,6 +271,8 @@ def get_bagel_pre_process_func(od_config: OmniDiffusionConfig): latent_downsample=latent_downsample, max_latent_size=max_latent_size, ) + sampling.height_not_provided = True + sampling.width_not_provided = True extra_args = request.sampling_params.extra_args or {} cfg_interval = tuple(extra_args.get("cfg_interval", (0.4, 1.0))) @@ -528,6 +581,7 @@ class BagelPipeline(nn.Module, SupportsComponentDiscovery, DiffusionPipelineProf f"latent_downsample={self.bagel.latent_downsample})." ) image_shape = (height, width) + image_shape_requested = _bagel_canvas_requested(sampling) extra_args = getattr(sampling, "extra_args", {}) or {} cfg_text_scale = extra_args.get("cfg_text_scale", 4.0) @@ -568,7 +622,15 @@ class BagelPipeline(nn.Module, SupportsComponentDiscovery, DiffusionPipelineProf gen_context["ropes"] = [seq_len] if sampling.kv_metadata and "image_shape" in sampling.kv_metadata: - image_shape = tuple(sampling.kv_metadata["image_shape"]) + if not image_shape_requested: + image_shape = tuple(sampling.kv_metadata["image_shape"]) + elif tuple(sampling.kv_metadata["image_shape"]) != image_shape: + logger.info( + "Generating at requested %dx%d instead of KV image shape %s", + image_shape[1], + image_shape[0], + sampling.kv_metadata["image_shape"], + ) branch_kvs = getattr(sampling, "cfg_branch_past_key_values", None) or {} branch_metadata = getattr(sampling, "cfg_branch_kv_metadata", None) or {} @@ -673,8 +735,15 @@ class BagelPipeline(nn.Module, SupportsComponentDiscovery, DiffusionPipelineProf image_input = [_resize_to_stride(img) for img in image_input] resized_w, resized_h = image_input[0].size - image_shape = (resized_h, resized_w) - logger.info(f"img2img: resized image to {resized_w}x{resized_h}") + if not image_shape_requested: + image_shape = (resized_h, resized_w) + logger.info( + "img2img: resized source to %dx%d, generating at %dx%d", + resized_w, + resized_h, + image_shape[1], + image_shape[0], + ) def vae_transforms(img): if img.mode != "RGB": diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 6541c11df..3bf3f3ff5 100644 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -128,6 +128,7 @@ from vllm_omni.entrypoints.openai.images.helpers import ( _check_max_generated_image_size, _choose_output_format, _extract_images_from_result, + _generated_size_str, _get_max_edit_input_images, _load_input_images, _update_if_not_none, @@ -2002,8 +2003,9 @@ def _build_image_generation_response( peak_memory_mb=peak_memory_mb, ), } - if request.size is not None: - response_kwargs["size"] = request.size + size = _generated_size_str(images, request.size) + if size is not None: + response_kwargs["size"] = size response = ImageGenerationResponse(**response_kwargs) if request.response_format == ResponseFormat.FILE: return response.stream_response() @@ -2428,6 +2430,8 @@ async def edit_images( _update_if_not_none(gen_params, "width", width) _update_if_not_none(gen_params, "height", height) + gen_params.width_not_provided = size_was_auto + gen_params.height_not_provided = size_was_auto # 3.4 Add optional parameters ONLY if provided _update_if_not_none(gen_params, "num_inference_steps", num_inference_steps) @@ -2580,7 +2584,7 @@ async def edit_images( created=int(time.time()), data=image_data, output_format=output_format, - size=size_str, + size=_generated_size_str(images, size_str), cot_output=cot_output, metrics=_build_image_response_metrics( response_metrics=response_metrics, diff --git a/vllm_omni/entrypoints/openai/images/helpers.py b/vllm_omni/entrypoints/openai/images/helpers.py index dd790ca3d..4001409fe 100644 --- a/vllm_omni/entrypoints/openai/images/helpers.py +++ b/vllm_omni/entrypoints/openai/images/helpers.py @@ -169,6 +169,14 @@ def _extract_images_from_result(result: Any) -> list[Any]: return [_normalize_image(img) for img in flattened] +def _generated_size_str(images: list[Any], fallback: str | None) -> str | None: + """``"WxH"`` of the first generated image, or ``fallback`` when it is unknown.""" + size = getattr(images[0], "size", None) if images else None + if isinstance(size, tuple) and len(size) == 2 and all(isinstance(value, Integral) for value in size): + return f"{size[0]}x{size[1]}" + return fallback + + async def _load_input_images( inputs: list[str], *, From 84977d95436f14c347b4284a23f6a64c3015c190 Mon Sep 17 00:00:00 2001 From: WeiQing Chen <40507679+david6666666@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:26:09 +0800 Subject: [PATCH 02/21] [Bugfix][MiniMax-H3] Fix Hopper keyframe and modulation precision (#7913) Signed-off-by: david6666666 <530634352@qq.com> --- .../minimax_h3/test_minimax_h3_contract.py | 70 ++++++++++++------- .../minimax_h3/test_minimax_h3_modulation.py | 69 ++++++------------ .../diffusion/layers/indexed_modulation.py | 49 +++---------- vllm_omni/diffusion/models/minimax_h3/vae.py | 15 ++-- 4 files changed, 89 insertions(+), 114 deletions(-) diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py index aca8bdb83..8cc3f1a9e 100644 --- a/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py @@ -3,7 +3,7 @@ import json import sys -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext from multiprocessing.reduction import ForkingPickler from types import SimpleNamespace from typing import Any, TypeVar @@ -2837,41 +2837,63 @@ def test_dit_encoder_selection_keeps_vae_resident(): component.offload_to_cpu.assert_not_called() -def test_keyframe_encode_pins_and_restores_cudnn_settings(): +@pytest.mark.parametrize( + ("capability", "allow_tf32"), + [((8, 6), True), ((9, 0), False), ((10, 0), True), ((10, 3), True), (None, True)], +) +@pytest.mark.parametrize("initial_tf32", [False, True]) +@pytest.mark.parametrize("fail_encode", [False, True]) +def test_keyframe_encode_pins_and_restores_cudnn_settings( + monkeypatch, capability, allow_tf32, initial_tf32, fail_encode +): + from vllm.platforms.interface import DeviceCapability + from vllm_omni.diffusion.models.minimax_h3.vae import ( _minimax_h3_keyframe_encode_context, ) + from vllm_omni.platforms import current_omni_platform + + def get_capability(device_id): + assert device_id == 1 + return DeviceCapability(*capability) if capability is not None else None + + monkeypatch.setattr(current_omni_platform, "is_cuda", lambda: True) + monkeypatch.setattr(current_omni_platform, "get_device_capability", get_capability) cudnn = torch.backends.cudnn - original = ( - cudnn.enabled, - cudnn.benchmark, - cudnn.deterministic, - cudnn.allow_tf32, - ) - try: - cudnn.enabled = False - cudnn.benchmark = True - cudnn.deterministic = False - cudnn.allow_tf32 = False - - with _minimax_h3_keyframe_encode_context(torch.device("cuda")): + with cudnn.flags(enabled=False, benchmark=True, deterministic=False, allow_tf32=initial_tf32): + error_context = pytest.raises(RuntimeError, match="encode failed") if fail_encode else nullcontext() + with error_context, _minimax_h3_keyframe_encode_context(torch.device("cuda:1")): assert cudnn.enabled assert not cudnn.benchmark assert cudnn.deterministic - assert cudnn.allow_tf32 + assert cudnn.allow_tf32 is allow_tf32 + if fail_encode: + raise RuntimeError("encode failed") assert not cudnn.enabled assert cudnn.benchmark assert not cudnn.deterministic - assert not cudnn.allow_tf32 - finally: - ( - cudnn.enabled, - cudnn.benchmark, - cudnn.deterministic, - cudnn.allow_tf32, - ) = original + assert cudnn.allow_tf32 is initial_tf32 + + +def test_keyframe_encode_cpu_keeps_cudnn_settings(monkeypatch): + from vllm_omni.diffusion.models.minimax_h3.vae import ( + _minimax_h3_keyframe_encode_context, + ) + from vllm_omni.platforms import current_omni_platform + + def unexpected_capability_query(device_id): + pytest.fail("CPU keyframe encode must not query CUDA capability") + + monkeypatch.setattr(current_omni_platform, "get_device_capability", unexpected_capability_query) + cudnn = torch.backends.cudnn + with cudnn.flags(enabled=False, benchmark=True, deterministic=False, allow_tf32=False): + with _minimax_h3_keyframe_encode_context(torch.device("cpu")): + assert not cudnn.enabled + assert cudnn.benchmark + assert not cudnn.deterministic + assert not cudnn.allow_tf32 @pytest.mark.parametrize("fail_encode", [False, True]) diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_modulation.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_modulation.py index fcca6f7ad..df3b22689 100644 --- a/tests/diffusion/models/minimax_h3/test_minimax_h3_modulation.py +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_modulation.py @@ -10,7 +10,6 @@ from vllm_omni.diffusion.layers.indexed_modulation import ( _MAX_1D_GRID_SIZE, _iter_row_chunks, _launch_row_chunks, - _use_hopper_bf16_affine_semantics, indexed_gate, indexed_gate_rms_norm_scale_shift, indexed_scale_shift_, @@ -194,52 +193,28 @@ def test_fused_modulation_preserves_bf16_residual_boundary() -> None: @pytest.mark.cuda -@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") -@pytest.mark.skipif( - torch.cuda.is_available() and torch.cuda.get_device_capability()[0] != 9, - reason="SM90-specific precision path", -) +@pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="CUDA required") @pytest.mark.skipif(not HAS_TRITON, reason="Triton required") -def test_hopper_fused_modulation_matches_pytorch_reference() -> None: - torch.manual_seed(42) - rows, hidden_size, conditions = 128, 3072, 4 - residual = torch.randn(rows, hidden_size, device="cuda", dtype=torch.bfloat16) - gate = torch.randn(conditions, hidden_size, device="cuda", dtype=torch.bfloat16) - branch = torch.randn(rows, hidden_size, device="cuda", dtype=torch.bfloat16) - weight = torch.randn(hidden_size, device="cuda", dtype=torch.bfloat16) - shift = torch.randn(conditions, hidden_size, device="cuda", dtype=torch.bfloat16) - scale = torch.randn(conditions, hidden_size, device="cuda", dtype=torch.bfloat16) - indices = torch.arange(rows, device="cuda") % conditions +@pytest.mark.parametrize("fused_gate", [False, True]) +def test_fused_modulation_keeps_fp32_affine_intermediates(fused_gate: bool) -> None: + device = current_omni_platform.get_torch_device() + rows, hidden_size = 2, 5376 + x = torch.ones(rows, hidden_size, device=device, dtype=torch.bfloat16) + weight = torch.full((hidden_size,), 1.125, device=device, dtype=x.dtype) + shift = torch.zeros(1, hidden_size, device=device, dtype=x.dtype) + scale = torch.full_like(shift, 0.09375) + indices = torch.zeros(rows, device=device, dtype=torch.int64) eps = 1e-6 + if fused_gate: + residual, actual = indexed_gate_rms_norm_scale_shift( + x, torch.zeros_like(shift), x, weight, shift, scale, indices, eps + ) + assert torch.equal(residual, x) + else: + actual = rms_norm_indexed_scale_shift(x, weight, shift, scale, indices, eps) - reference_residual = (residual + gate.index_select(0, indices) * branch).to(torch.bfloat16) - normalized = reference_residual.float() - variance = normalized.pow(2).mean(-1, keepdim=True) - normalized = (weight.float() * (normalized * torch.rsqrt(variance + eps))).to(torch.bfloat16) - reference_modulated = (normalized * (1.0 + scale.index_select(0, indices)) + shift.index_select(0, indices)).to( - torch.bfloat16 - ) - - actual_residual, actual_modulated = indexed_gate_rms_norm_scale_shift( - residual, - gate, - branch, - weight, - shift, - scale, - indices, - eps, - ) - - torch.testing.assert_close(actual_residual, reference_residual, atol=5e-2, rtol=5e-2) - torch.testing.assert_close(actual_modulated, reference_modulated, atol=5e-2, rtol=5e-2) - - -@pytest.mark.cuda -@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") -def test_hopper_precision_dispatch_is_architecture_scoped(monkeypatch) -> None: - tensor = torch.empty(0, device="cuda") - monkeypatch.setattr(torch.cuda, "get_device_capability", lambda _device: (9, 0)) - assert _use_hopper_bf16_affine_semantics(tensor) - monkeypatch.setattr(torch.cuda, "get_device_capability", lambda _device: (10, 3)) - assert not _use_hopper_bf16_affine_semantics(tensor) + # Without the RMS epsilon, 1.125 * 1.09375 = 1.23046875 is exactly + # halfway between two BF16 values. FP32 normalization keeps the epsilon + # and rounds down to 1.2265625. Premature BF16 rounding drops the epsilon + # and instead rounds the tie up to 1.234375, changing every output row. + assert torch.equal(actual, torch.full_like(x, 1.2265625)) diff --git a/vllm_omni/diffusion/layers/indexed_modulation.py b/vllm_omni/diffusion/layers/indexed_modulation.py index 0f4f2e59e..9fd402dec 100644 --- a/vllm_omni/diffusion/layers/indexed_modulation.py +++ b/vllm_omni/diffusion/layers/indexed_modulation.py @@ -24,18 +24,6 @@ def _launch_row_chunks(kernel, rows: int, device_type: str, *args, **kwargs) -> kernel[(chunk_rows,)](*args, row_offset, **kwargs) -def _use_hopper_bf16_affine_semantics(x: torch.Tensor) -> bool: - """Use the unfused AdaLN dtype boundaries on Hopper. - - The fused FP32 modulation path is stable on Blackwell, but the SM90 - kernel can accumulate enough extra precision in the repeated RMSNorm and - AdaLN operations to move the 50-step I2VA result below its CI threshold. - Keep the existing path on other architectures until their numerical - baselines are independently validated. - """ - return x.device.type == "cuda" and torch.cuda.get_device_capability(x.device)[0] == 9 - - @triton.jit def _indexed_scale_shift_kernel( output_ptr, @@ -114,7 +102,6 @@ def _rms_norm_indexed_scale_shift_kernel( stride_scale_row, stride_indices, row_offset, - use_bf16_affine: tl.constexpr, block_n: tl.constexpr, ): row = tl.program_id(0) + row_offset @@ -125,17 +112,13 @@ def _rms_norm_indexed_scale_shift_kernel( x = tl.load(x_ptr + row * stride_x_row + columns, mask=mask, other=0.0).to(tl.float32) weight = tl.load(weight_ptr + columns, mask=mask, other=0.0).to(tl.float32) variance = tl.sum(x * x, axis=0) / hidden_size - if use_bf16_affine: - # Match the unfused RMSNorm output consumed by AdaLN on Hopper. - normalized = (x * tl.rsqrt(variance + eps) * weight).to(tl.bfloat16) - shift = tl.load(shift_ptr + index * stride_shift_row + columns, mask=mask, other=0.0) - scale = tl.load(scale_ptr + index * stride_scale_row + columns, mask=mask, other=0.0) - output = normalized * (scale + 1.0) + shift - else: - normalized = x * tl.rsqrt(variance + eps) * weight - shift = tl.load(shift_ptr + index * stride_shift_row + columns, mask=mask, other=0.0).to(tl.float32) - scale = tl.load(scale_ptr + index * stride_scale_row + columns, mask=mask, other=0.0).to(tl.float32) - output = normalized * (1.0 + scale) + shift + # Keep RMSNorm and AdaLN intermediates in FP32 on every architecture. + # Rounding the normalized value to BF16 before the affine transform + # changes the fused computation, including values at rounding boundaries. + normalized = x * tl.rsqrt(variance + eps) * weight + shift = tl.load(shift_ptr + index * stride_shift_row + columns, mask=mask, other=0.0).to(tl.float32) + scale = tl.load(scale_ptr + index * stride_scale_row + columns, mask=mask, other=0.0).to(tl.float32) + output = normalized * (1.0 + scale) + shift tl.store( output_ptr + row * hidden_size + columns, output, @@ -163,7 +146,6 @@ def _indexed_gate_rms_norm_scale_shift_kernel( stride_scale_row, stride_indices, row_offset, - use_bf16_affine: tl.constexpr, block_n: tl.constexpr, ): row = tl.program_id(0) + row_offset @@ -180,17 +162,10 @@ def _indexed_gate_rms_norm_scale_shift_kernel( weight = tl.load(weight_ptr + columns, mask=mask, other=0.0).to(tl.float32) variance = tl.sum(updated * updated, axis=0) / hidden_size - if use_bf16_affine: - # Match the unfused RMSNorm output consumed by AdaLN on Hopper. - normalized = (updated * tl.rsqrt(variance + eps) * weight).to(tl.bfloat16) - shift = tl.load(shift_ptr + index * stride_shift_row + columns, mask=mask, other=0.0) - scale = tl.load(scale_ptr + index * stride_scale_row + columns, mask=mask, other=0.0) - output = normalized * (scale + 1.0) + shift - else: - normalized = updated * tl.rsqrt(variance + eps) * weight - shift = tl.load(shift_ptr + index * stride_shift_row + columns, mask=mask, other=0.0).to(tl.float32) - scale = tl.load(scale_ptr + index * stride_scale_row + columns, mask=mask, other=0.0).to(tl.float32) - output = normalized * (1.0 + scale) + shift + normalized = updated * tl.rsqrt(variance + eps) * weight + shift = tl.load(shift_ptr + index * stride_shift_row + columns, mask=mask, other=0.0).to(tl.float32) + scale = tl.load(scale_ptr + index * stride_scale_row + columns, mask=mask, other=0.0).to(tl.float32) + output = normalized * (1.0 + scale) + shift tl.store( modulated_out_ptr + row * hidden_size + columns, output, @@ -300,7 +275,6 @@ def rms_norm_indexed_scale_shift( shift.stride(0), scale.stride(0), indices.stride(0), - use_bf16_affine=_use_hopper_bf16_affine_semantics(x), block_n=triton.next_power_of_2(hidden_size), num_warps=8, ) @@ -354,7 +328,6 @@ def indexed_gate_rms_norm_scale_shift( shift.stride(0), scale.stride(0), indices.stride(0), - use_bf16_affine=_use_hopper_bf16_affine_semantics(residual), block_n=triton.next_power_of_2(hidden_size), num_warps=8, ) diff --git a/vllm_omni/diffusion/models/minimax_h3/vae.py b/vllm_omni/diffusion/models/minimax_h3/vae.py index ed28029bb..c614ea0f2 100644 --- a/vllm_omni/diffusion/models/minimax_h3/vae.py +++ b/vllm_omni/diffusion/models/minimax_h3/vae.py @@ -31,6 +31,7 @@ from vllm_omni.diffusion.offloader.module_residency import ( BoundedAllocatorCache, PinnedModuleStager, ) +from vllm_omni.platforms import current_omni_platform from .chunked_decode import decode_h3_chunks from .ops import install_h3_vae_optimizations @@ -56,15 +57,19 @@ def _minimax_h3_keyframe_encode_context( yield return - # The official keyframe latent uses cuDNN's TF32 convolution path. The - # default non-deterministic algorithm can select numerically different - # reductions on H100s, and the difference is amplified by the denoiser. - # Pin both the algorithm and math mode for this sensitive encode only. + # Deterministic cuDNN algorithms can still use reduced-precision TF32. + # On Hopper, TF32 can shift the keyframe latent enough to push I2VA below + # the reference accuracy thresholds. Use FP32 convolutions for this + # sensitive encode only; preserve the existing math mode elsewhere. + allow_tf32 = True + if current_omni_platform.is_cuda(): + capability = current_omni_platform.get_device_capability(device.index or 0) + allow_tf32 = capability is None or capability.major != 9 with torch.backends.cudnn.flags( enabled=True, benchmark=False, deterministic=True, - allow_tf32=True, + allow_tf32=allow_tf32, ): yield From fb2e3535af3745d27f6932ba81439e784f39c459 Mon Sep 17 00:00:00 2001 From: shiyichuan <93317314+CarrotSwordsman@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:01:03 +0800 Subject: [PATCH 03/21] [Bugfix][Engine] Preserve text_encoder_tp_size in OmniEngineArgs (#7652) Signed-off-by: mershi Co-authored-by: mershi --- tests/engine/test_arg_utils.py | 37 ++++++++++++++++++++++++++++++++++ vllm_omni/engine/arg_utils.py | 6 ++++++ 2 files changed, 43 insertions(+) diff --git a/tests/engine/test_arg_utils.py b/tests/engine/test_arg_utils.py index 7132d7512..d40a9c226 100644 --- a/tests/engine/test_arg_utils.py +++ b/tests/engine/test_arg_utils.py @@ -543,3 +543,40 @@ def test_tensor_parallel_size_none_is_handled(): ) assert isinstance(args, dict) assert "tensor_parallel_size" not in args + + +# For https://github.com/vllm-project/vllm-omni/issues/7564 +def test_from_cli_args_preserves_text_encoder_tp_size(): + """`--text-encoder-tp-size` must survive from_cli_args field filtering. + + Library callers build engine args via ``OmniEngineArgs.from_cli_args``; + the dataclass field filter drops any namespace attribute the dataclass + does not declare, silently resetting the diffusion text-encoder TP to 1. + """ + engine_args = OmniEngineArgs.from_cli_args( + SimpleNamespace(text_encoder_tp_size=2), + ) + assert engine_args.text_encoder_tp_size == 2 + + +# For https://github.com/vllm-project/vllm-omni/issues/7564 +def test_text_encoder_tp_size_reaches_default_diffusion_parallel_config(): + """The preserved CLI value must land in DiffusionParallelConfig. + + Mirrors the library flow: from_cli_args -> kwargs -> the generic + diffusion fallback, which resolves the field through + ``DiffusionParallelConfig.from_stage_overrides``. + """ + from dataclasses import asdict + + from vllm_omni.config.config_factory import StageConfigFactory + + engine_args = OmniEngineArgs.from_cli_args( + SimpleNamespace(text_encoder_tp_size=2), + ) + stage_cfg = StageConfigFactory.create_default_diffusion( + {k: v for k, v in asdict(engine_args).items() if v is not None}, + )[0] + + parallel_config = stage_cfg["engine_args"]["parallel_config"] + assert parallel_config["text_encoder_tp_size"] == 2 diff --git a/vllm_omni/engine/arg_utils.py b/vllm_omni/engine/arg_utils.py index 939b19d50..8fb6c80e9 100644 --- a/vllm_omni/engine/arg_utils.py +++ b/vllm_omni/engine/arg_utils.py @@ -223,6 +223,12 @@ class OmniEngineArgs(EngineArgs): # Diffusion request-mode batch admission (forwarded to OmniDiffusionConfig). request_batch_max_wait_ms: float = 0.0 fa_deterministic: bool = False + # Tensor-parallel degree for the diffusion text encoder (forwarded to + # DiffusionParallelConfig via the generic diffusion fallback). Declared + # here so ``from_cli_args`` field filtering keeps ``--text-encoder-tp-size`` + # for library callers (#7564); registered pipelines may consume it through + # their own stage_cli_aliases or deploy YAML. + text_encoder_tp_size: int | None = None @classmethod def _add_omni_specific_args(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: From 8efd3c2f6dbf3fd3852168d77852c3528c7932fc Mon Sep 17 00:00:00 2001 From: wtz2333 <2955110911@qq.com> Date: Mon, 21 Sep 2026 17:02:15 +0800 Subject: [PATCH 04/21] [Feature] Add experimental LingBot World last-step KV reuse (#7816) Signed-off-by: wtz2333 <2955110911@qq.com> --- recipes/Robbyant/LingBot-World-2.0.md | 20 +++++++ .../test_pipeline_lingbot_world.py | 54 +++++++++++++++---- .../models/lingbot_world/dmd_block.py | 35 ++++++++---- .../models/lingbot_world/pipeline.py | 7 ++- 4 files changed, 94 insertions(+), 22 deletions(-) diff --git a/recipes/Robbyant/LingBot-World-2.0.md b/recipes/Robbyant/LingBot-World-2.0.md index 24a36653d..7add2f7d8 100644 --- a/recipes/Robbyant/LingBot-World-2.0.md +++ b/recipes/Robbyant/LingBot-World-2.0.md @@ -53,6 +53,26 @@ source frames, at least as many as requested and at most 4096. The runtime consumes only the prefix needed by the request. Longer rollouts also require sufficient device memory. +## Experimental KV writeback reuse + +To reuse KV from the final denoising step instead of running the additional +clean-latent writeback, pass this option in the `model_config` supplied to +`Omni` or `AsyncOmni`: + +```python +model_config = {"lingbot_reuse_last_step_kv": True} +``` + +The default is `False`. The value must be a boolean, not a string or integer. +It is validated and fixed when the pipeline instance is constructed; changing +it requires a new instance. It is not a per-request `extra_args` option. +The previous `VLLM_OMNI_LINGBOT_REUSE_LAST_STEP_KV` environment variable is no +longer read. + +This experimental mode stores the final noisy probe's KV rather than clean +`x0` KV, so generated outputs can differ. The four denoising steps, chunk +boundaries, and paged-context finalization remain the same. + ## Realtime in-process generation (deprecated) Prefer [Streaming video serving](#streaming-video-serving) for mid-session diff --git a/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py b/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py index 91ae00b7d..1ccc7aadc 100644 --- a/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py +++ b/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py @@ -1358,10 +1358,34 @@ def test_first_frame_condition_and_camera_fold_match_transformer_contract() -> N assert expected_camera.shape == (1, 384, 3, 2, 2) -def test_fixed_dmd_transition_and_cache_commit_trace() -> None: +@pytest.mark.parametrize("value", ["true", "false", 1, 0, None]) +def test_reuse_last_step_kv_rejects_non_boolean_config(value: object) -> None: + config = _od_config() + config.model_config["lingbot_reuse_last_step_kv"] = value + with pytest.raises(ValueError, match="lingbot_reuse_last_step_kv must be a bool"): + _pipeline(_load_pipeline_module(), od_config=config) + + +def test_reuse_last_step_kv_is_fixed_per_instance(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VLLM_OMNI_LINGBOT_REUSE_LAST_STEP_KV", "1") + module = _load_pipeline_module() + config = _od_config() + config.model_config["lingbot_reuse_last_step_kv"] = True + reuse = _pipeline(module, od_config=config) + default = _pipeline(module) + config.model_config["lingbot_reuse_last_step_kv"] = False + assert reuse._dmd_blocks.reuse_last_step_kv is True + assert default._dmd_blocks.reuse_last_step_kv is False + assert reuse._dmd_blocks is reuse._dmd_blocks + + +@pytest.mark.parametrize("reuse_last_step_kv", [False, True]) +def test_fixed_dmd_transition_and_cache_commit_trace(reuse_last_step_kv: bool) -> None: module = _load_pipeline_module() transformer = _RecordingTransformer() - pipeline = _pipeline(module, transformer=transformer) + config = _od_config() + config.model_config["lingbot_reuse_last_step_kv"] = reuse_last_step_kv + pipeline = _pipeline(module, transformer=transformer, od_config=config) result = pipeline(_request()) @@ -1383,14 +1407,18 @@ def test_fixed_dmd_transition_and_cache_commit_trace() -> None: current = x0 torch.testing.assert_close(result.output, current) + expected_timesteps = [timestep for timestep, _ in warped_schedule] + if not reuse_last_step_kv: + expected_timesteps.append(0.0) torch.testing.assert_close( - torch.cat([call["timestep"] for call in transformer.calls]), - torch.tensor([*(timestep for timestep, _ in warped_schedule), 0.0]), + torch.cat([call["timestep"] for call in transformer.calls]), torch.tensor(expected_timesteps) ) - assert [call["update_cache"] for call in transformer.calls] == [False, False, False, False, True] - assert [call["start_frame"] for call in transformer.calls] == [0, 0, 0, 0, 0] + commit_flags = [False] * (len(expected_timesteps) - 1) + [True] + assert [call["update_cache"] for call in transformer.calls] == commit_flags + assert [call["start_frame"] for call in transformer.calls] == [0] * len(expected_timesteps) assert len({call["cache_id"] for call in transformer.calls}) == 1 - torch.testing.assert_close(transformer.calls[-1]["hidden_states"][:, :16], result.output) + committed_latent = result.output + warped_schedule[-1][1] if reuse_last_step_kv else result.output + torch.testing.assert_close(transformer.calls[-1]["hidden_states"][:, :16], committed_latent) @pytest.mark.parametrize( @@ -2389,10 +2417,13 @@ def test_forward_rejects_a_stepwise_camera_action_script(extra_args) -> None: pipeline(_request(sampling=sampling)) -def test_stepwise_progress_metadata_and_commit_trace() -> None: +@pytest.mark.parametrize("reuse_last_step_kv", [False, True]) +def test_stepwise_progress_metadata_and_commit_trace(reuse_last_step_kv: bool) -> None: module = _load_pipeline_module() transformer = _RecordingTransformer() - pipeline = _pipeline(module, transformer=transformer) + config = _od_config() + config.model_config["lingbot_reuse_last_step_kv"] = reuse_last_step_kv + pipeline = _pipeline(module, transformer=transformer, od_config=config) pipeline._ar_height = 16 pipeline._ar_width = 16 state = _stepwise_state(num_frames=21) @@ -2406,8 +2437,9 @@ def test_stepwise_progress_metadata_and_commit_trace() -> None: assert [output.chunk_index for output in outputs] == [0, 1] assert all(output.total_chunks == 2 for output in outputs) assert outputs[-1].finished is True - assert [call["update_cache"] for call in transformer.calls] == [False, False, False, False, True] * 2 - assert [call["start_frame"] for call in transformer.calls] == [0] * 5 + [3] * 5 + calls_per_chunk = 4 if reuse_last_step_kv else 5 + assert [call["update_cache"] for call in transformer.calls] == ([False] * (calls_per_chunk - 1) + [True]) * 2 + assert [call["start_frame"] for call in transformer.calls] == [0] * calls_per_chunk + [3] * calls_per_chunk assert fake.commits == ["main", "main"] for chunk_index, output in enumerate(outputs): metadata = output.output["metadata"]["ar_diffusion"] diff --git a/vllm_omni/diffusion/models/lingbot_world/dmd_block.py b/vllm_omni/diffusion/models/lingbot_world/dmd_block.py index b179eb773..27da62149 100644 --- a/vllm_omni/diffusion/models/lingbot_world/dmd_block.py +++ b/vllm_omni/diffusion/models/lingbot_world/dmd_block.py @@ -7,9 +7,10 @@ over every block, stepwise execution calls ``probe_step`` / ``apply_transition`` one denoise step at a time and ``commit_block_kv`` from ``post_decode`` -- so the math lives here once and cannot drift between the two modes. -A block is four probes that must not touch KV, then one clean-x0 commit that -does. Whether a call sees paged (session-bound) or request-local KV is decided -by the ``ar`` argument, so this class never reaches back into the pipeline. +By default a block is four probes followed by one clean-x0 KV commit. +The experimental last-step reuse mode instead commits the fourth noisy probe. +Whether a call sees paged (session-bound) or request-local KV is decided by +the ``ar`` argument, so this class never reaches back into the pipeline. """ from __future__ import annotations @@ -19,6 +20,7 @@ from typing import TYPE_CHECKING, Any, cast import torch from diffusers.utils.torch_utils import randn_tensor +from vllm.logger import init_logger from vllm_omni.diffusion.forward_context import set_forward_context_denoise_step_idx from vllm_omni.diffusion.models.lingbot_world.transformer import ( @@ -33,6 +35,9 @@ if TYPE_CHECKING: from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState +logger = init_logger(__name__) + + @dataclass(frozen=True) class ARBlockContext: """The bound AR-Diffusion session one block runs against. @@ -55,10 +60,14 @@ class LingBotDMDBlockRunner: *, device: torch.device, enforce_eager: bool, + reuse_last_step_kv: bool = False, ) -> None: self.transformer = transformer self.device = device self.enforce_eager = enforce_eager + # Experimental approximation: cache x_t/t_last features instead of x0/t=0. + self.reuse_last_step_kv = reuse_last_step_kv + logger.info("LingBot experimental last-step KV reuse: %s", self.reuse_last_step_kv) # ── cache selection ────────────────────────────────────────────────── @@ -114,8 +123,8 @@ class LingBotDMDBlockRunner: ) -> torch.Tensor: """Predict flow for one denoise step. - A probe never writes KV: only the clean x0 of a finished block may - enter the cache, which is ``commit_block_kv``'s job. + Normally only clean x0 enters KV. The experimental reuse mode commits + the final noisy probe instead; the fixed DMD schedule has four probes. ``camera_cache`` is the block's camera-modulation cache when the caller holds it across separate calls (the stepwise path); ``generate_block`` @@ -128,14 +137,15 @@ class LingBotDMDBlockRunner: # Checkpoint channel contract: # [noise/x_t(16), temporal_mask(4), image_latent(16)] -> 36. model_input = torch.cat((current_latents.to(dtype=condition.dtype), condition), dim=1) + commit_probe = self.reuse_last_step_kv and step_index == 3 flow_prediction = self.transformer( hidden_states=model_input, timestep=timestep, encoder_hidden_states=prompt_embeds, camera_hidden_states=camera, - cache=self._block_cache(condition=condition, cache=cache, ar=ar, commit_current=False), + cache=self._block_cache(condition=condition, cache=cache, ar=ar, commit_current=commit_probe), start_frame=start_frame, - update_cache=False, + update_cache=commit_probe, camera_modulation_cache=camera_cache, ) if flow_prediction.shape != current_latents.shape: @@ -183,10 +193,15 @@ class LingBotDMDBlockRunner: ) -> None: """Write the finished block's clean x0 into KV and commit its pages. - The fifth transformer call of a block, deliberately not a denoise step: - on the stepwise path it belongs to ``post_decode()``. ``camera_cache`` - is as in ``probe_step``. + By default this is the fifth transformer call, at t=0. Experimental + reuse only finalizes the pages written by the fourth probe. On the + stepwise path both modes finalize from ``post_decode()``. + ``camera_cache`` is as in ``probe_step``. """ + if self.reuse_last_step_kv: + if ar is not None: + ar.state.commit_paged_context(ar.branch) + return # Commit K/V only for the final clean block, never for noisy probes. cache_input = torch.cat((latents.to(dtype=condition.dtype), condition), dim=1) if not self.enforce_eager: diff --git a/vllm_omni/diffusion/models/lingbot_world/pipeline.py b/vllm_omni/diffusion/models/lingbot_world/pipeline.py index 48c2ca568..ae24c0138 100644 --- a/vllm_omni/diffusion/models/lingbot_world/pipeline.py +++ b/vllm_omni/diffusion/models/lingbot_world/pipeline.py @@ -581,6 +581,11 @@ class LingBotWorldCausalDMDPipeline( del prefix _validate_parallel_config(od_config) self.od_config = od_config + model_config = getattr(od_config, "model_config", None) or {} + reuse_last_step_kv = model_config.get("lingbot_reuse_last_step_kv", False) + if not isinstance(reuse_last_step_kv, bool): + raise ValueError("model_config.lingbot_reuse_last_step_kv must be a bool.") + self._reuse_last_step_kv = reuse_last_step_kv self.device = get_local_device() dtype = getattr(od_config, "dtype", torch.bfloat16) model = od_config.model @@ -666,7 +671,6 @@ class LingBotWorldCausalDMDPipeline( self.vae_scale_factor_temporal = int(getattr(self.vae.config, "scale_factor_temporal", 4)) self.vae_scale_factor_spatial = int(getattr(self.vae.config, "scale_factor_spatial", 8)) - model_config = getattr(od_config, "model_config", None) or {} self._ar_height = int(model_config.get("ar_diffusion_height", 480)) self._ar_width = int(model_config.get("ar_diffusion_width", 832)) self._ar_diffusion_kv_state: ARDiffusionKVState | None = None @@ -1337,6 +1341,7 @@ class LingBotWorldCausalDMDPipeline( self.transformer, device=self.device, enforce_eager=bool(self.od_config.enforce_eager), + reuse_last_step_kv=self._reuse_last_step_kv, ) self._dmd_block_runner = runner return runner From 5356357cf0bf95a7818ba2452b2248b5a0e59af2 Mon Sep 17 00:00:00 2001 From: Zhou Taichang Date: Mon, 21 Sep 2026 19:00:46 +0800 Subject: [PATCH 05/21] [LingBot World] Shard the streaming VAE decode across the Ulysses ranks (#7651) Signed-off-by: tzhouam Co-authored-by: Claude Fable 5.1 --- .../distributed/test_wan_spatial_shard.py | 177 ++++++++++++++++++ .../test_pipeline_lingbot_world.py | 96 +++++++++- .../deploy/lingbot_world_v2_stepwise.yaml | 3 + .../autoencoders/wan_spatial_shard.py | 35 +++- .../models/lingbot_world/pipeline.py | 78 +++++++- 5 files changed, 378 insertions(+), 11 deletions(-) diff --git a/tests/diffusion/distributed/test_wan_spatial_shard.py b/tests/diffusion/distributed/test_wan_spatial_shard.py index 23c29c938..27177745e 100644 --- a/tests/diffusion/distributed/test_wan_spatial_shard.py +++ b/tests/diffusion/distributed/test_wan_spatial_shard.py @@ -493,6 +493,84 @@ def test_dist_zero_pad_only_applies_global_width_edges(monkeypatch: pytest.Monke assert last.shape == (1, 1, 2, 3) +@pytest.mark.core_model +@pytest.mark.cpu +class _PlainDecoder(torch.nn.Module): + """A decoder with nothing to patch, so the install only wraps ``forward``.""" + + def __init__(self): + super().__init__() + self.calls: list[dict] = [] + + def forward(self, x, feat_cache=None, feat_idx=None, first_chunk=False): + self.calls.append({"feat_cache": feat_cache, "feat_idx": feat_idx, "first_chunk": first_chunk}) + return x + + +@pytest.mark.core_model +@pytest.mark.cpu +@pytest.mark.parametrize("world_size, dst", [(4, -1), (4, 4), (4, 5), (1, 1), (4, 1.5)]) +def test_install_rejects_invalid_destination_before_mutating_decoder(monkeypatch, world_size, dst): + monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (0, world_size)) + vae = SimpleNamespace(decoder=torch.nn.Identity()) + original_forward = vae.decoder.forward + + def unexpected_patch(*args, **kwargs): + pytest.fail("invalid destination must fail before patching modules") + + monkeypatch.setattr(wan_spatial_shard, "_patch_decoder_modules", unexpected_patch) + with pytest.raises(ValueError, match="dst must be None or an integer"): + wan_spatial_shard.install_wan_spatial_shard_decode(vae, object(), dst=dst) + assert vae.decoder.forward == original_forward + assert not getattr(vae, "_vllm_omni_wan_spatial_shard_installed", False) + + +def _install_on_plain_decoder(monkeypatch: pytest.MonkeyPatch, *, dst): + monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (1, 2)) + monkeypatch.setattr( + wan_spatial_shard, + "split_for_parallel_decode", + lambda x, *, upsample_count, split_dim, group: (x, x.shape[wan_spatial_shard._spatial_dim(split_dim)]), + ) + gathers: list[dict] = [] + + def gather(x, *, expected_extent, split_dim, group, dst=None): + gathers.append({"expected_extent": expected_extent, "split_dim": split_dim, "dst": dst}) + return x + + monkeypatch.setattr(wan_spatial_shard, "gather_and_trim_extent", gather) + vae = SimpleNamespace(decoder=_PlainDecoder()) + wan_spatial_shard.install_wan_spatial_shard_decode(vae, object(), split_dim="width", dst=dst) + return vae, gathers + + +@pytest.mark.core_model +@pytest.mark.cpu +@pytest.mark.parametrize("dst", [0, 1, None]) +def test_install_passes_the_assembling_rank_through_to_the_gather(monkeypatch: pytest.MonkeyPatch, dst): + vae, gathers = _install_on_plain_decoder(monkeypatch, dst=dst) + cache = [None] + + out = vae.decoder(torch.zeros(1, 3, 1, 4, 6), feat_cache=cache, feat_idx=[0], first_chunk=True) + + assert out.shape == (1, 3, 1, 4, 6) + assert gathers == [{"expected_extent": 6, "split_dim": "width", "dst": dst}] + # The streaming arguments reach the wrapped decoder untouched. + assert vae.decoder.calls == [{"feat_cache": cache, "feat_idx": [0], "first_chunk": True}] + assert vae._vllm_omni_wan_spatial_shard_dst == dst + + +@pytest.mark.core_model +@pytest.mark.cpu +def test_install_refuses_to_change_the_assembling_rank(monkeypatch: pytest.MonkeyPatch): + vae, _ = _install_on_plain_decoder(monkeypatch, dst=None) + + # Same settings again: a no-op, as before. + wan_spatial_shard.install_wan_spatial_shard_decode(vae, object(), split_dim="width", dst=None) + with pytest.raises(ValueError, match="already patched to assemble on None"): + wan_spatial_shard.install_wan_spatial_shard_decode(vae, object(), split_dim="width", dst=0) + + @pytest.mark.core_model @pytest.mark.cpu def test_spatial_shard_height_gate_falls_back_for_partial_group(monkeypatch: pytest.MonkeyPatch): @@ -626,6 +704,105 @@ def _spatial_shard_decode_worker(rank: int, split_dim: str, return_dict, master_ dist.destroy_process_group() +_STREAMING_SHARD_CHUNKS = 3 + + +def _streaming_shard_worker(rank: int, return_dict, master_port: str) -> None: + """Sharded streaming decode with every rank keeping the frame, against an unsharded streaming reference. + + Two sessions are interleaved chunk by chunk, so the per-session temporal cache + and the halo exchange are exercised across successive calls, then one session + is reset and must reproduce its opening chunk. Every rank checks every chunk. + """ + from vllm_omni.diffusion.distributed.parallel_state import ( + destroy_model_parallel, + get_sp_group, + init_distributed_environment, + initialize_model_parallel, + ) + from vllm_omni.experimental.ar_diffusion.streaming_decode import WanStreamingDecoder + + os.environ["MASTER_ADDR"] = "127.0.0.1" + os.environ["MASTER_PORT"] = master_port + device = current_omni_platform.get_torch_device(rank) + current_omni_platform.set_device(device) + dtype = torch.float32 + backend = current_omni_platform.dist_backend + init_distributed_environment(world_size=_SPATIAL_SHARD_WORLD_SIZE, rank=rank, local_rank=rank, backend=backend) + initialize_model_parallel( + sequence_parallel_size=_SPATIAL_SHARD_WORLD_SIZE, ulysses_degree=_SPATIAL_SHARD_WORLD_SIZE, backend=backend + ) + # VLLM_OMNI_TEST_WAN_VAE may point at a local diffusers checkpoint (with or + # without a ``vae`` subfolder) for machines without hub access. + model = os.environ.get("VLLM_OMNI_TEST_WAN_VAE", _SPATIAL_SHARD_MODEL) + local_vae_only = os.path.isdir(model) and not os.path.isdir(os.path.join(model, "vae")) + load = {"torch_dtype": dtype} if local_vae_only else {"subfolder": _SPATIAL_SHARD_SUBFOLDER, "torch_dtype": dtype} + try: + reference_vae = DistributedAutoencoderKLWan.from_pretrained(model, **load).to(device=device, dtype=dtype).eval() + sharded_vae = DistributedAutoencoderKLWan.from_pretrained(model, **load).to(device=device, dtype=dtype).eval() + wan_spatial_shard.install_wan_spatial_shard_decode( + sharded_vae, get_sp_group().device_group, split_dim="width", dst=None + ) + reference = WanStreamingDecoder(reference_vae, bytes_per_pixel_fp32=1.0) + sharded = WanStreamingDecoder(sharded_vae, bytes_per_pixel_fp32=1.0) + + generator = torch.Generator(device=device).manual_seed(0) + # Width 104 latents = 832 px: not divisible by two ranks' halo-padded shards without padding. + shape = (1, reference_vae.config.z_dim, 1, _SPATIAL_SHARD_LATENT_HEIGHT, _SPATIAL_SHARD_LATENT_WIDTH) + chunks = { + name: [ + torch.randn(shape, generator=generator, device=device, dtype=dtype) + for _ in range(_STREAMING_SHARD_CHUNKS) + ] + for name in ("a", "b") + } + max_diff = 0.0 + with torch.inference_mode(): + ref_states = {name: reference.new_decode_state(name) for name in chunks} + shard_states = {name: sharded.new_decode_state(name) for name in chunks} + for index in range(_STREAMING_SHARD_CHUNKS): + for name in ("a", "b"): + expected = reference.decode_chunk(chunks[name][index], ref_states[name]).float() + got = sharded.decode_chunk(chunks[name][index], shard_states[name]).float() + if got.shape != expected.shape: + raise AssertionError( + f"rank {rank} {name}[{index}]: {tuple(got.shape)} != {tuple(expected.shape)}" + ) + max_diff = max(max_diff, (got - expected).abs().max().item()) + # Reset session a: a fresh state must reproduce its opening chunk, on this rank too. + shard_states["a"].release() + ref_states["a"].release() + shard_states["a"] = sharded.new_decode_state("a") + ref_states["a"] = reference.new_decode_state("a") + expected = reference.decode_chunk(chunks["a"][0], ref_states["a"]).float() + got = sharded.decode_chunk(chunks["a"][0], shard_states["a"]).float() + max_diff = max(max_diff, (got - expected).abs().max().item()) + return_dict[f"max_abs_diff_rank{rank}"] = max_diff + return_dict[f"shape_rank{rank}"] = tuple(got.shape) + finally: + destroy_model_parallel() + if dist.is_initialized(): + dist.destroy_process_group() + + +@pytest.mark.full_model +@pytest.mark.diffusion +@pytest.mark.parallel +@hardware_test(res={"cuda": ["H100", "B200"]}, num_cards=_SPATIAL_SHARD_WORLD_SIZE) +def test_streaming_shard_decode_matches_unsharded_streaming_on_every_rank(): + manager = mp.get_context("spawn").Manager() + return_dict = manager.dict() + mp.spawn(_streaming_shard_worker, args=(return_dict, "29510"), nprocs=_SPATIAL_SHARD_WORLD_SIZE, join=True) + + for rank in range(_SPATIAL_SHARD_WORLD_SIZE): + assert f"max_abs_diff_rank{rank}" in return_dict, f"rank {rank} did not report" + diff = return_dict[f"max_abs_diff_rank{rank}"] + shape = return_dict[f"shape_rank{rank}"] + print(f"streaming shard rank {rank}: max_abs_diff={diff:.6e} shape={shape}") + assert shape[-1] == _SPATIAL_SHARD_LATENT_WIDTH * 8 and shape[-2] == _SPATIAL_SHARD_LATENT_HEIGHT * 8 + assert diff <= _SPATIAL_SHARD_TOLERANCE, f"rank {rank}: {diff} exceeds {_SPATIAL_SHARD_TOLERANCE}" + + @pytest.mark.full_model @pytest.mark.diffusion @pytest.mark.parallel diff --git a/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py b/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py index 1ccc7aadc..b5a57ce75 100644 --- a/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py +++ b/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py @@ -737,6 +737,99 @@ def test_component_discovery_uses_official_checkpoint_contract() -> None: assert module._loader_state.prefetch_calls == [("checkpoint", ("tokenizer", "text_encoder", "vae"), False)] +def _ulysses_config(size: int): + parallel_config = _od_config().parallel_config + parallel_config.sequence_parallel_size = size + parallel_config.ulysses_degree = size + return _od_config(parallel_config=parallel_config) + + +def _record_shard_install(monkeypatch, module, *, world_size: int): + """Stand in for the Ulysses group and the decoder patch; return what the install was asked for.""" + installs: list[dict] = [] + group = object() + + def install(vae, group_arg, split_dim, *, dst): + installs.append({"vae": vae, "group": group_arg, "split_dim": split_dim, "dst": dst}) + + monkeypatch.setattr(module, "install_wan_spatial_shard_decode", install) + monkeypatch.setattr(module.LingBotWorldCausalDMDPipeline, "_vae_shard_group", lambda self: (group, world_size)) + return installs, group + + +def test_a_multi_rank_deployment_shards_the_decoder_across_the_ulysses_ranks(monkeypatch) -> None: + """Multi-rank deployments shard decode by default.""" + module = _load_pipeline_module() + installs, group = _record_shard_install(monkeypatch, module, world_size=2) + + pipeline = module.LingBotWorldCausalDMDPipeline(od_config=_ulysses_config(2)) + + # Along the width, and assembled on every rank: each rank's post_decode consumes the frame. + assert installs == [{"vae": pipeline.vae, "group": group, "split_dim": "width", "dst": None}] + assert pipeline._vae_shard_split_dim == "width" + + +def test_a_single_rank_deployment_leaves_the_decoder_alone(monkeypatch) -> None: + module = _load_pipeline_module() + installs, _ = _record_shard_install(monkeypatch, module, world_size=1) + + pipeline = module.LingBotWorldCausalDMDPipeline(od_config=_ulysses_config(1)) + + assert installs == [] and pipeline._vae_shard_split_dim is None + + +@pytest.mark.parametrize("enabled", [True, False]) +@pytest.mark.parametrize("width, local_width", [(832, 208), (840, 216)]) +def test_streaming_decode_reservation_uses_padded_shards(monkeypatch, enabled, width, local_width): + module = _load_pipeline_module() + installs, _ = _record_shard_install(monkeypatch, module, world_size=4) + config = _ulysses_config(4) + config.model_config.update(lingbot_vae_spatial_sharding=enabled, ar_diffusion_height=480, ar_diffusion_width=width) + pipeline = module.LingBotWorldCausalDMDPipeline(od_config=config) + from vllm_omni.experimental.ar_diffusion.streaming_decode import WanStreamingDecoder + + # Exercise the real byte estimator without constructing a checkpoint VAE. + decoder = object.__new__(WanStreamingDecoder) + decoder._bytes_per_pixel_fp32 = 16.0 + monkeypatch.setattr(pipeline, "_streaming_decoder", lambda: decoder) + expected_width = local_width if enabled else width + assert pipeline._streaming_decode_bytes_per_session() == 16 * 480 * expected_width + assert len(installs) == int(enabled) + assert pipeline._vae_shard_world_size == (4 if enabled else 1) + assert config.parallel_config.sequence_parallel_size == config.parallel_config.ulysses_degree == 4 + + +def test_disabling_vae_sharding_does_not_require_a_shard_group(monkeypatch): + module = _load_pipeline_module() + config = _ulysses_config(4) + config.model_config["lingbot_vae_spatial_sharding"] = False + + def unexpected_group(self): + pytest.fail("disabled VAE sharding must not access its process group") + + monkeypatch.setattr(module.LingBotWorldCausalDMDPipeline, "_vae_shard_group", unexpected_group) + pipeline = module.LingBotWorldCausalDMDPipeline(od_config=config) + assert pipeline._vae_shard_split_dim is None + + +@pytest.mark.parametrize("value", ["false", 0, None]) +def test_vae_sharding_switch_requires_a_boolean(value): + module = _load_pipeline_module() + config = _ulysses_config(4) + config.model_config["lingbot_vae_spatial_sharding"] = value + with pytest.raises(ValueError, match="lingbot_vae_spatial_sharding must be a boolean"): + module.LingBotWorldCausalDMDPipeline(od_config=config) + + +def test_vae_shard_refuses_a_group_of_the_wrong_size(monkeypatch) -> None: + module = _load_pipeline_module() + installs, _ = _record_shard_install(monkeypatch, module, world_size=4) + + with pytest.raises(RuntimeError, match="sequence_parallel_size=2 but the Ulysses group has 4 ranks"): + module.LingBotWorldCausalDMDPipeline(od_config=_ulysses_config(2)) + assert installs == [] + + @pytest.mark.parametrize( ("field", "value", "feature"), [ @@ -758,8 +851,9 @@ def test_unsupported_parallel_modes_fail_before_component_loading(field: str, va assert module._loader_state.prefetch_calls == [] -def test_pure_ulysses_parallel_config_is_supported() -> None: +def test_pure_ulysses_parallel_config_is_supported(monkeypatch) -> None: module = _load_pipeline_module() + _record_shard_install(monkeypatch, module, world_size=2) parallel_config = _od_config().parallel_config parallel_config.sequence_parallel_size = 2 parallel_config.ulysses_degree = 2 diff --git a/vllm_omni/deploy/lingbot_world_v2_stepwise.yaml b/vllm_omni/deploy/lingbot_world_v2_stepwise.yaml index 3fc950e4c..9a00b4d6c 100644 --- a/vllm_omni/deploy/lingbot_world_v2_stepwise.yaml +++ b/vllm_omni/deploy/lingbot_world_v2_stepwise.yaml @@ -34,6 +34,9 @@ stages: extra_args: flow_shift: 5.0 model_config: + # Multi-rank width sharding changes floating-point numerics at shard borders. + # Set false for full-frame VAE decode while keeping DiT sequence parallelism. + lingbot_vae_spatial_sharding: true ar_diffusion_height: 480 ar_diffusion_width: 832 ar_diffusion_kv_config: diff --git a/vllm_omni/diffusion/distributed/autoencoders/wan_spatial_shard.py b/vllm_omni/diffusion/distributed/autoencoders/wan_spatial_shard.py index 16fdd13a4..e3f0ef5c3 100644 --- a/vllm_omni/diffusion/distributed/autoencoders/wan_spatial_shard.py +++ b/vllm_omni/diffusion/distributed/autoencoders/wan_spatial_shard.py @@ -781,22 +781,34 @@ def _decoder_upsample_count(decoder: nn.Module) -> int: return count -def install_wan_spatial_shard_decode(vae: Any, group: dist.ProcessGroup, split_dim: str = "height") -> None: +def install_wan_spatial_shard_decode( + vae: Any, + group: dist.ProcessGroup, + split_dim: str = "height", + *, + dst: int | None = 0, +) -> None: """Patch ``vae.decoder`` once for spatially-sharded decode. This mutates the already-loaded decoder in place by swapping its spatial convolutions/padding for halo-exchanging variants and wrapping ``decoder.forward``. The patch is permanent for the lifetime of the VAE instance and is applied only once (subsequent calls are no-ops). A given - instance is bound to a single ``split_dim``; switching between - ``"height"`` and ``"width"`` requires a fresh VAE instance and raises here - otherwise. + instance is bound to a single ``split_dim`` and ``dst``; switching either + requires a fresh VAE instance and raises here otherwise. - Only group-relative rank 0 assembles the final decoded frame, mirroring the - distributed tiled-decode ``broadcast_result=False`` contract; the other ranks - take part in the collectives but return an empty placeholder. + With ``dst=0`` (the default) only group-relative rank 0 assembles the + final decoded frame, mirroring the distributed tiled-decode + ``broadcast_result=False`` contract; the other ranks take part in the + collectives but return an empty placeholder. ``dst=None`` lets every rank + keep the assembled frame, for a caller whose every rank goes on to + consume the decoded pixels; the gather is an all-gather either way, so + this costs no extra communication. """ _spatial_dim(split_dim) + _, world_size = _rank_world(group) + if dst is not None and (not isinstance(dst, int) or not 0 <= dst < world_size): + raise ValueError(f"Wan spatial-shard dst must be None or an integer in [0, {world_size}), got {dst!r}.") if getattr(vae, "_vllm_omni_wan_spatial_shard_installed", False): installed_split_dim = getattr(vae, "_vllm_omni_wan_spatial_shard_split_dim", "height") if installed_split_dim != split_dim: @@ -804,6 +816,12 @@ def install_wan_spatial_shard_decode(vae: Any, group: dist.ProcessGroup, split_d "Wan spatial-shard VAE decoder was already patched for " f"{installed_split_dim!r} split; create a fresh VAE instance to use {split_dim!r} split." ) + installed_dst = getattr(vae, "_vllm_omni_wan_spatial_shard_dst", 0) + if installed_dst != dst: + raise ValueError( + "Wan spatial-shard VAE decoder was already patched to assemble on " + f"{installed_dst!r}; create a fresh VAE instance to assemble on {dst!r}." + ) return decoder = getattr(vae, "decoder", None) if decoder is None: @@ -844,11 +862,12 @@ def install_wan_spatial_shard_decode(vae: Any, group: dist.ProcessGroup, split_d out = orig_forward(x, feat_cache=feat_cache, feat_idx=feat_idx, first_chunk=first_chunk) finally: _SPATIAL_SHARD_CONTEXT.reset(token) - return gather_and_trim_extent(out, expected_extent=expected_extent, split_dim=split_dim, group=group, dst=0) + return gather_and_trim_extent(out, expected_extent=expected_extent, split_dim=split_dim, group=group, dst=dst) decoder.forward = MethodType(_forward, decoder) vae._vllm_omni_wan_spatial_shard_installed = True vae._vllm_omni_wan_spatial_shard_split_dim = split_dim + vae._vllm_omni_wan_spatial_shard_dst = dst logger.info("Installed Wan VAE %s-sharded decode.", split_dim) diff --git a/vllm_omni/diffusion/models/lingbot_world/pipeline.py b/vllm_omni/diffusion/models/lingbot_world/pipeline.py index ae24c0138..8bb83ef80 100644 --- a/vllm_omni/diffusion/models/lingbot_world/pipeline.py +++ b/vllm_omni/diffusion/models/lingbot_world/pipeline.py @@ -26,6 +26,7 @@ from vllm.model_executor.models.utils import AutoWeightsLoader from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import DistributedAutoencoderKLWan +from vllm_omni.diffusion.distributed.autoencoders.wan_spatial_shard import install_wan_spatial_shard_decode from vllm_omni.diffusion.distributed.utils import get_local_device from vllm_omni.diffusion.interaction.mixin import InteractionMixin from vllm_omni.diffusion.interaction.modality_handlers.camera import CameraSession @@ -92,6 +93,10 @@ logger = init_logger(__name__) # measured to hold. _STREAMING_DECODE_BYTES_PER_PIXEL_FP32 = 37832 * 1024 / (64 * 64) +# The frame dimension the decoder is sharded along when the pipeline runs on +# more than one Ulysses rank; see ``_install_sharded_vae_decode``. +_VAE_SHARD_SPLIT_DIM = "width" + if TYPE_CHECKING: from tqdm.std import tqdm as TqdmProgressBar @@ -580,6 +585,7 @@ class LingBotWorldCausalDMDPipeline( super().__init__() del prefix _validate_parallel_config(od_config) + parallel_config = getattr(od_config, "parallel_config", None) self.od_config = od_config model_config = getattr(od_config, "model_config", None) or {} reuse_last_step_kv = model_config.get("lingbot_reuse_last_step_kv", False) @@ -683,6 +689,14 @@ class LingBotWorldCausalDMDPipeline( self._streaming_decode_states: dict[str, StreamingDecodeState] = {} self._cached_streaming_decoder: WanStreamingDecoder | None = None self._streaming_decode_unsupported = False + self._vae_shard_split_dim: str | None = None + self._vae_shard_world_size = 1 + vae_sharding = model_config.get("lingbot_vae_spatial_sharding", True) + if not isinstance(vae_sharding, bool): + raise ValueError("lingbot_vae_spatial_sharding must be a boolean.") + sequence_parallel_size = int(getattr(parallel_config, "sequence_parallel_size", 1) or 1) + if sequence_parallel_size > 1 and vae_sharding: + self._install_sharded_vae_decode(sequence_parallel_size) self.setup_diffusion_pipeline_profiler( profiler_targets=[ "vae.encode", @@ -803,9 +817,24 @@ class LingBotWorldCausalDMDPipeline( decoder = self._streaming_decoder() if decoder is None: return 0 + height, width = self._ar_height, self._ar_width + if self._vae_shard_split_dim is not None: + # The decoder pads the latent extent to a multiple of the group + # size before splitting. Account for that padding on every rank, + # including ranks whose final pixels are trimmed after gathering. + scale = self.vae_scale_factor_spatial + extent = width if self._vae_shard_split_dim == "width" else height + latent_extent = (extent + scale - 1) // scale + local_extent = (latent_extent + self._vae_shard_world_size - 1) // self._vae_shard_world_size + if self._vae_shard_split_dim == "width": + width = local_extent * scale + else: + height = local_extent * scale + # Temporal caches retain local inputs, before spatial halo exchange. + # Halo receive buffers belong to the decoder, not to each session. return decoder.declared_state_bytes( - height=self._ar_height, - width=self._ar_width, + height=height, + width=width, dtype=self.vae.dtype, ) @@ -1659,6 +1688,51 @@ class LingBotWorldCausalDMDPipeline( tile_latent_min_width = int(getattr(self.vae, "tile_sample_min_width", None) or 0) // compression return bool(latents.shape[-2] > tile_latent_min_height or latents.shape[-1] > tile_latent_min_width) + def _vae_shard_group(self) -> tuple[Any, int]: + """The Ulysses ranks the decoder is sharded across, and how many there are.""" + from vllm_omni.diffusion.distributed.parallel_state import get_sp_group + + group = get_sp_group().device_group + return group, torch.distributed.get_world_size(group=group) + + def _install_sharded_vae_decode(self, sequence_parallel_size: int) -> None: + """Shard the Wan decoder across the Ulysses ranks along the width. + + The DiT runs on ``sequence_parallel_size`` ranks, and the decode of + one AR block used to run on every one of them in full: each rank + decoded the whole frame so that its own ``post_decode`` could carry + on. Splitting the frame across those same ranks with halo exchange at + the convolution borders makes each decode a quarter of the work at + four ranks; every rank still ends the block with the assembled frame + because the gather is an all-gather, so nothing downstream changes. + The streaming decoder is unaffected: ``feat_cache``, ``feat_idx`` and + ``first_chunk`` pass straight through to the patched decoder, and the + per-session temporal cache shrinks by the same factor. + + This is the default for multi-rank deployments; setting + ``model_config.lingbot_vae_spatial_sharding=False`` keeps full-frame + decode on each rank without changing DiT parallelism. + ``vae_patch_parallel_size`` stays rejected because it would make the + registry set up the tiled distributed executor and force + tiling on a decoder that is already sharded. Width rather than height + because at 480x832 it is the longer side, so each rank keeps a + 208-pixel shard against the same halo, and it is the split this was + measured on: 269.9 -> 98.7 ms per block on 4xH200. Numerically it is + not bit-exact against a single-rank decode: the halo-exchanged + convolutions accumulate in a different order at the shard borders + (max abs diff 0.0195 on [-1, 1] at 480x832). + """ + group, world_size = self._vae_shard_group() + if world_size != sequence_parallel_size: + raise RuntimeError( + f"LingBot World sequence_parallel_size={sequence_parallel_size} but the Ulysses group has " + f"{world_size} ranks." + ) + install_wan_spatial_shard_decode(self.vae, group, split_dim=_VAE_SHARD_SPLIT_DIM, dst=None) + self._vae_shard_split_dim = _VAE_SHARD_SPLIT_DIM + self._vae_shard_world_size = world_size + logger.info("LingBot World VAE decode sharded along %s across %d ranks.", _VAE_SHARD_SPLIT_DIM, world_size) + def _streaming_decoder(self) -> WanStreamingDecoder | None: """The session-owned streaming decoder, or ``None`` on a VAE that cannot stream. From 5e28163ba5b329f5bed36272aca2f9f65e53ef88 Mon Sep 17 00:00:00 2001 From: zhumingjue138 Date: Mon, 21 Sep 2026 19:03:18 +0800 Subject: [PATCH 06/21] [Test] add stability test case for High-priority model (#7571) Signed-off-by: zhumingjue --- .../references/production-validation.md | 2 +- docs/contributing/ci/test_execution_guide.md | 4 +- docs/contributing/ci/test_writing_guide.md | 4 +- ...mage.py => run_stability_hunyuan_image.py} | 6 +- .../scripts/run_stability_hunyuanvideo15.py | 70 ++++++++++++++ .../scripts/run_stability_minicpmo_4_5.py | 71 +++++++++++++++ .../scripts/run_stability_minimax_h3.py | 91 +++++++++++++++++++ ...n3_omni.py => run_stability_qwen3_omni.py} | 6 +- ...wen3_tts.py => run_stability_qwen3_tts.py} | 6 +- ...n_image.py => run_stability_qwen_image.py} | 6 +- ...bility_wan22.py => run_stability_wan22.py} | 6 +- .../stability/tests/test_hunyuan_image.json | 51 ++++++++++- .../stability/tests/test_hunyuanvideo15.json | 57 ++++++++++++ .../stability/tests/test_minicpmo_4_5.json | 53 +++++++++++ .../dfx/stability/tests/test_minimax_h3.json | 62 +++++++++++++ .../dfx/stability/tests/test_qwen3_omni.json | 4 +- tests/dfx/stability/tests/test_qwen3_tts.json | 1 + .../dfx/stability/tests/test_qwen_image.json | 40 ++++++++ tests/dfx/stability/tests/test_wan22.json | 2 + tools/nightly/run_nightly_jobs.sh | 38 ++++++-- 20 files changed, 557 insertions(+), 23 deletions(-) rename tests/dfx/stability/scripts/{test_stability_hunyuan_image.py => run_stability_hunyuan_image.py} (92%) create mode 100644 tests/dfx/stability/scripts/run_stability_hunyuanvideo15.py create mode 100644 tests/dfx/stability/scripts/run_stability_minicpmo_4_5.py create mode 100644 tests/dfx/stability/scripts/run_stability_minimax_h3.py rename tests/dfx/stability/scripts/{test_stability_qwen3_omni.py => run_stability_qwen3_omni.py} (92%) rename tests/dfx/stability/scripts/{test_stability_qwen3_tts.py => run_stability_qwen3_tts.py} (92%) rename tests/dfx/stability/scripts/{test_stability_qwen_image.py => run_stability_qwen_image.py} (92%) rename tests/dfx/stability/scripts/{test_stability_wan22.py => run_stability_wan22.py} (92%) create mode 100644 tests/dfx/stability/tests/test_hunyuanvideo15.json create mode 100644 tests/dfx/stability/tests/test_minicpmo_4_5.json create mode 100644 tests/dfx/stability/tests/test_minimax_h3.json diff --git a/.claude/skills/production-add-diffusion-model/references/production-validation.md b/.claude/skills/production-add-diffusion-model/references/production-validation.md index eed568415..3b2f505fa 100644 --- a/.claude/skills/production-add-diffusion-model/references/production-validation.md +++ b/.claude/skills/production-add-diffusion-model/references/production-validation.md @@ -340,7 +340,7 @@ On supported GPU hosts, wrap a test or soak with the repository monitor: ```bash bash tests/dfx/stability/scripts/resource_monitor.sh run --backend gpu -- \ - pytest -s -v tests/dfx/stability/scripts/test_stability_.py -m slow + pytest -s -v tests/dfx/stability/scripts/run_stability_.py -m slow ``` Check the target revision's monitor backends. Do not claim CPU/NPU monitoring diff --git a/docs/contributing/ci/test_execution_guide.md b/docs/contributing/ci/test_execution_guide.md index 7597f72e4..d3b02d27f 100644 --- a/docs/contributing/ci/test_execution_guide.md +++ b/docs/contributing/ci/test_execution_guide.md @@ -177,10 +177,10 @@ Failed jobs: 1/2 cd tests # Stability: Qwen3-Omni - pytest -s -v dfx/stability/scripts/test_stability_qwen3_omni.py -m slow + pytest -s -v dfx/stability/scripts/run_stability_qwen3_omni.py -m slow # Stability: Wan2.2 (v1/videos diffusion benchmark loop) - pytest -s -v dfx/stability/scripts/test_stability_wan22.py -m slow + pytest -s -v dfx/stability/scripts/run_stability_wan22.py -m slow # Reliability: Qwen3-Omni (H100 × 2) pytest -s -v dfx/reliability/test_reliability_qwen3_omni.py -m slow diff --git a/docs/contributing/ci/test_writing_guide.md b/docs/contributing/ci/test_writing_guide.md index 762863bb3..47a629112 100644 --- a/docs/contributing/ci/test_writing_guide.md +++ b/docs/contributing/ci/test_writing_guide.md @@ -555,7 +555,7 @@ L5 level testing focuses on the performance of model services under ***long-runn - ***Trigger Timing***: **`Weekly`** (weekly) or **`Days before Release`** (several days before a major release). Due to long execution times, the frequency is lower. - ***Run Command***: - - ***Stability***: `pytest -s -v tests/dfx/stability/scripts/test_stability_qwen3_omni.py` or `pytest -s -v tests/dfx/stability/scripts/test_stability_wan22.py` (or add `test_stability_.py` alongside a matching JSON config) + - ***Stability***: `pytest -s -v tests/dfx/stability/scripts/run_stability_qwen3_omni.py` or `pytest -s -v tests/dfx/stability/scripts/run_stability_wan22.py` (or add `run_stability_.py` alongside a matching JSON config) - ***Reliability***: `pytest -s -v tests/dfx/reliability/test_reliability_.py -m slow` (current suites: `qwen3_omni`, `wan22`, `hunyuan_image`). - ***Script Example***: @@ -564,7 +564,7 @@ L5 level testing focuses on the performance of model services under ***long-runn ##### Stability -When you want to add L5-level stability test cases, add or extend the appropriate JSON file under `tests/dfx/stability/tests/` (for example `test_qwen3_omni.json` for Omni bench traffic, or `test_wan22.json` for diffusion `/v1/videos` workloads). Pair the JSON with `tests/dfx/stability/scripts/test_stability_.py`. The following illustrates the Qwen3-Omni shape: +When you want to add L5-level stability test cases, add or extend the appropriate JSON file under `tests/dfx/stability/tests/` (for example `test_qwen3_omni.json` for Omni bench traffic, or `test_wan22.json` for diffusion `/v1/videos` workloads). Pair the JSON with `tests/dfx/stability/scripts/run_stability_.py`. The following illustrates the Qwen3-Omni shape: ```json { diff --git a/tests/dfx/stability/scripts/test_stability_hunyuan_image.py b/tests/dfx/stability/scripts/run_stability_hunyuan_image.py similarity index 92% rename from tests/dfx/stability/scripts/test_stability_hunyuan_image.py rename to tests/dfx/stability/scripts/run_stability_hunyuan_image.py index be2aec805..d9db77205 100644 --- a/tests/dfx/stability/scripts/test_stability_hunyuan_image.py +++ b/tests/dfx/stability/scripts/run_stability_hunyuan_image.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """ Hunyuan-Image stability: OmniServer (diffusion) + ``diffusion_benchmark_serving.py``. @@ -34,8 +37,7 @@ server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) i benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping) -@pytest.mark.slow -@pytest.mark.diffusion +# Pytest marks (hardware, local_model, slow, diffusion) come from test_hunyuan_image.json. @pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True) def test_stability_hunyuan_image(omni_server, stability_benchmark_params): diff --git a/tests/dfx/stability/scripts/run_stability_hunyuanvideo15.py b/tests/dfx/stability/scripts/run_stability_hunyuanvideo15.py new file mode 100644 index 000000000..a0e7dfce8 --- /dev/null +++ b/tests/dfx/stability/scripts/run_stability_hunyuanvideo15.py @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +""" +HunyuanVideo-1.5 I2V stability: OmniServer (diffusion) + ``diffusion_benchmark_serving.py`` / ``v1/videos``. + +Configuration: ``tests/dfx/stability/tests/test_hunyuanvideo15.json``. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.dfx.conftest import ( + create_benchmark_indices, + create_test_parameter_mapping, + create_unique_server_pytest_params, + load_configs, +) +from tests.dfx.stability.helpers import _run_one_diffusion_batch, run_stability_benchmark_loop + +STABILITY_DIR = Path(__file__).resolve().parent.parent +DEPLOY_CONFIGS_DIR = STABILITY_DIR / "deploy" +CONFIG_FILE_PATH = str(STABILITY_DIR / "tests" / "test_hunyuanvideo15.json") +DEFAULT_NUM_PROMPTS_PER_BATCH = 50 +STABILITY_SERVER_TIMEOUT_ARGS = ["--stage-init-timeout", "600", "--init-timeout", "900"] + +try: + BENCHMARK_CONFIGS = load_configs(CONFIG_FILE_PATH) +except FileNotFoundError: + BENCHMARK_CONFIGS = [] + +test_params = create_unique_server_pytest_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else [] +server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) if BENCHMARK_CONFIGS else {} +benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping) + + +# Pytest marks (hardware, local_model, slow, diffusion) come from test_hunyuanvideo15.json. +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +@pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True) +def test_stability_hunyuanvideo15(omni_server, stability_benchmark_params): + test_name = stability_benchmark_params["test_name"] + params = stability_benchmark_params["params"] + duration_sec = params.get("duration_sec", 300) + num_prompts_per_batch = params.get("num_prompts_per_batch", DEFAULT_NUM_PROMPTS_PER_BATCH) + request_rate = params.get("request_rate") + max_concurrency = params.get("max_concurrency") + + bench_params = { + k: v + for k, v in params.items() + if k not in ("duration_sec", "request_rate", "max_concurrency", "num_prompts_per_batch") + } + + result = run_stability_benchmark_loop( + host=omni_server.host, + port=omni_server.port, + model=omni_server.model, + duration_sec=duration_sec, + params=bench_params, + request_rate=request_rate, + max_concurrency=max_concurrency, + result_dir=str(STABILITY_DIR), + num_prompts_per_batch=num_prompts_per_batch, + run_one_batch=_run_one_diffusion_batch, + ) + + assert result.get("failed", 0) == 0, f"[{test_name}] Failed requests detected: {result.get('errors', [])}" + assert result.get("completed", 0) > 0, f"[{test_name}] No requests completed" diff --git a/tests/dfx/stability/scripts/run_stability_minicpmo_4_5.py b/tests/dfx/stability/scripts/run_stability_minicpmo_4_5.py new file mode 100644 index 000000000..fcb0590ce --- /dev/null +++ b/tests/dfx/stability/scripts/run_stability_minicpmo_4_5.py @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +""" +MiniCPM-o-4.5 stability: OmniServer + ``vllm bench serve --omni`` for a fixed duration. + +Configuration: ``tests/dfx/stability/tests/test_minicpmo_4_5.json``. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.dfx.conftest import ( + create_benchmark_indices, + create_test_parameter_mapping, + create_unique_server_pytest_params, + load_configs, +) +from tests.dfx.stability.helpers import _run_one_vllm_bench_batch, run_stability_benchmark_loop + +STABILITY_DIR = Path(__file__).resolve().parent.parent +DEPLOY_CONFIGS_DIR = STABILITY_DIR / "deploy" +CONFIG_FILE_PATH = str(STABILITY_DIR / "tests" / "test_minicpmo_4_5.json") +DEFAULT_NUM_PROMPTS_PER_BATCH = 20 +STABILITY_SERVER_TIMEOUT_ARGS = ["--stage-init-timeout", "600"] + +try: + BENCHMARK_CONFIGS = load_configs(CONFIG_FILE_PATH) +except FileNotFoundError: + BENCHMARK_CONFIGS = [] + +test_params = create_unique_server_pytest_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else [] +server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) if BENCHMARK_CONFIGS else {} +benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping) + + +# Pytest marks (hardware, local_model, slow, omni) come from test_minicpmo_4_5.json. +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +@pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True) +def test_stability_minicpmo_4_5(omni_server, stability_benchmark_params): + test_name = stability_benchmark_params["test_name"] + params = stability_benchmark_params["params"] + duration_sec = params.get("duration_sec", 300) + num_prompts_per_batch = params.get("num_prompts_per_batch", DEFAULT_NUM_PROMPTS_PER_BATCH) + request_rate = params.get("request_rate") + max_concurrency = params.get("max_concurrency") + + bench_params = { + k: v + for k, v in params.items() + if k not in ("duration_sec", "request_rate", "max_concurrency", "num_prompts_per_batch") + } + + result = run_stability_benchmark_loop( + host=omni_server.host, + port=omni_server.port, + model=omni_server.model, + duration_sec=duration_sec, + params=bench_params, + request_rate=request_rate, + max_concurrency=max_concurrency, + result_dir=str(STABILITY_DIR), + num_prompts_per_batch=num_prompts_per_batch, + run_one_batch=_run_one_vllm_bench_batch, + ) + + assert result.get("failed", 0) == 0, f"[{test_name}] Failed requests detected: {result.get('errors', [])}" + assert result.get("completed", 0) > 0, f"[{test_name}] No requests completed" diff --git a/tests/dfx/stability/scripts/run_stability_minimax_h3.py b/tests/dfx/stability/scripts/run_stability_minimax_h3.py new file mode 100644 index 000000000..252d830d5 --- /dev/null +++ b/tests/dfx/stability/scripts/run_stability_minimax_h3.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +""" +MiniMax-H3 T2V stability: OmniServer (diffusion) + ``diffusion_benchmark_serving.py`` / ``v1/videos``. + +Configuration: ``tests/dfx/stability/tests/test_minimax_h3.json``. + +``--lora-path`` is resolved like e2e FastH3 / Buildkite ``test-merge.yml``: +set ``VLLM_TEST_MINIMAX_H3_FASTH3_LORA`` to the adapter file, or populate the local +HF cache for ``FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA``. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.dfx.conftest import ( + create_benchmark_indices, + create_test_parameter_mapping, + create_unique_server_pytest_params, + load_configs, +) +from tests.dfx.stability.helpers import _run_one_diffusion_batch, run_stability_benchmark_loop +from tests.e2e.online_serving.minimax_h3._common import FASTH3_LORA + +STABILITY_DIR = Path(__file__).resolve().parent.parent +DEPLOY_CONFIGS_DIR = STABILITY_DIR / "deploy" +CONFIG_FILE_PATH = str(STABILITY_DIR / "tests" / "test_minimax_h3.json") +DEFAULT_NUM_PROMPTS_PER_BATCH = 20 +STABILITY_SERVER_TIMEOUT_ARGS = ["--stage-init-timeout", "1800", "--init-timeout", "1800"] + + +def _inject_fasth3_lora_path(configs: list[dict]) -> list[dict]: + """Fill serve_args.lora-path from VLLM_TEST_MINIMAX_H3_FASTH3_LORA / HF cache.""" + if not FASTH3_LORA: + return configs + for config in configs: + serve_args = config.setdefault("server_params", {}).setdefault("serve_args", {}) + serve_args["lora-path"] = FASTH3_LORA + return configs + + +try: + BENCHMARK_CONFIGS = _inject_fasth3_lora_path(load_configs(CONFIG_FILE_PATH)) +except FileNotFoundError: + BENCHMARK_CONFIGS = [] + +test_params = create_unique_server_pytest_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else [] +server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) if BENCHMARK_CONFIGS else {} +benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping) + + +# Pytest marks (hardware, local_model, slow, diffusion) come from test_minimax_h3.json. +@pytest.mark.skipif( + FASTH3_LORA is None, + reason="set VLLM_TEST_MINIMAX_H3_FASTH3_LORA or populate the local HF cache", +) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +@pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True) +def test_stability_minimax_h3(omni_server, stability_benchmark_params): + test_name = stability_benchmark_params["test_name"] + params = stability_benchmark_params["params"] + duration_sec = params.get("duration_sec", 300) + num_prompts_per_batch = params.get("num_prompts_per_batch", DEFAULT_NUM_PROMPTS_PER_BATCH) + request_rate = params.get("request_rate") + max_concurrency = params.get("max_concurrency") + + bench_params = { + k: v + for k, v in params.items() + if k not in ("duration_sec", "request_rate", "max_concurrency", "num_prompts_per_batch") + } + + result = run_stability_benchmark_loop( + host=omni_server.host, + port=omni_server.port, + model=omni_server.model, + duration_sec=duration_sec, + params=bench_params, + request_rate=request_rate, + max_concurrency=max_concurrency, + result_dir=str(STABILITY_DIR), + num_prompts_per_batch=num_prompts_per_batch, + run_one_batch=_run_one_diffusion_batch, + ) + + assert result.get("failed", 0) == 0, f"[{test_name}] Failed requests detected: {result.get('errors', [])}" + assert result.get("completed", 0) > 0, f"[{test_name}] No requests completed" diff --git a/tests/dfx/stability/scripts/test_stability_qwen3_omni.py b/tests/dfx/stability/scripts/run_stability_qwen3_omni.py similarity index 92% rename from tests/dfx/stability/scripts/test_stability_qwen3_omni.py rename to tests/dfx/stability/scripts/run_stability_qwen3_omni.py index 98ab5ee89..ddb3339d1 100644 --- a/tests/dfx/stability/scripts/test_stability_qwen3_omni.py +++ b/tests/dfx/stability/scripts/run_stability_qwen3_omni.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """ Qwen3-Omni stability: OmniServer + ``vllm bench serve --omni`` for a fixed duration. @@ -34,8 +37,7 @@ server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) i benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping) -@pytest.mark.slow -@pytest.mark.omni +# Pytest marks (hardware, local_model, slow, omni) come from test_qwen3_omni.json. @pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True) def test_stability_qwen3_omni(omni_server, stability_benchmark_params): diff --git a/tests/dfx/stability/scripts/test_stability_qwen3_tts.py b/tests/dfx/stability/scripts/run_stability_qwen3_tts.py similarity index 92% rename from tests/dfx/stability/scripts/test_stability_qwen3_tts.py rename to tests/dfx/stability/scripts/run_stability_qwen3_tts.py index 293b723ca..dde525b36 100644 --- a/tests/dfx/stability/scripts/test_stability_qwen3_tts.py +++ b/tests/dfx/stability/scripts/run_stability_qwen3_tts.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """ Qwen3-TTS stability: OmniServer + ``vllm bench serve --omni`` for a fixed duration. @@ -34,8 +37,7 @@ server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) i benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping) -@pytest.mark.slow -@pytest.mark.tts +# Pytest marks (hardware, local_model, slow, tts) come from test_qwen3_tts.json. @pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True) def test_stability_qwen3_tts(omni_server, stability_benchmark_params): diff --git a/tests/dfx/stability/scripts/test_stability_qwen_image.py b/tests/dfx/stability/scripts/run_stability_qwen_image.py similarity index 92% rename from tests/dfx/stability/scripts/test_stability_qwen_image.py rename to tests/dfx/stability/scripts/run_stability_qwen_image.py index 62b0cd86c..84e15fccb 100644 --- a/tests/dfx/stability/scripts/test_stability_qwen_image.py +++ b/tests/dfx/stability/scripts/run_stability_qwen_image.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """ Qwen-Image stability: OmniServer (diffusion) + ``diffusion_benchmark_serving.py``. @@ -34,8 +37,7 @@ server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) i benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping) -@pytest.mark.slow -@pytest.mark.diffusion +# Pytest marks (hardware, local_model, slow, diffusion) come from test_qwen_image.json. @pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True) def test_stability_qwen_image(omni_server, stability_benchmark_params): diff --git a/tests/dfx/stability/scripts/test_stability_wan22.py b/tests/dfx/stability/scripts/run_stability_wan22.py similarity index 92% rename from tests/dfx/stability/scripts/test_stability_wan22.py rename to tests/dfx/stability/scripts/run_stability_wan22.py index aae0ae429..08c711d85 100644 --- a/tests/dfx/stability/scripts/test_stability_wan22.py +++ b/tests/dfx/stability/scripts/run_stability_wan22.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """ Wan2.2 T2V stability: OmniServer (diffusion) + ``diffusion_benchmark_serving.py`` / ``v1/videos``. @@ -34,8 +37,7 @@ server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) i benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping) -@pytest.mark.slow -@pytest.mark.diffusion +# Pytest marks (hardware, local_model, slow, diffusion) come from test_wan22.json. @pytest.mark.parametrize("omni_server", test_params, indirect=True) @pytest.mark.parametrize("stability_benchmark_params", benchmark_indices, indirect=True) def test_stability_wan22(omni_server, stability_benchmark_params): diff --git a/tests/dfx/stability/tests/test_hunyuan_image.json b/tests/dfx/stability/tests/test_hunyuan_image.json index b468ceec3..43c92d1e2 100644 --- a/tests/dfx/stability/tests/test_hunyuan_image.json +++ b/tests/dfx/stability/tests/test_hunyuan_image.json @@ -7,10 +7,11 @@ "res": { "cuda": "H800" }, - "num_cards": 2 + "num_cards": 4 } }, "local_model", + "slow", "diffusion" ], "server_params": { @@ -44,5 +45,53 @@ "enable_negative_prompt": true } ] + }, + { + "test_name": "test_hunyuan_image_stability_tp2_sp2_a3", + "description": "A3: same as perf test_hunyuan_image_tp2_sp2 — DiT-only, TP=2 USP=2, 4 dies, no fp8", + "mark": [ + { + "hardware_marks": { + "res": { + "npu": "A3" + }, + "num_cards": 4 + } + }, + "local_model", + "slow", + "diffusion" + ], + "server_params": { + "model": "tencent/HunyuanImage-3.0-Instruct", + "serve_args": { + "deploy-config": "vllm_omni/deploy/hunyuan_image3_dit.yaml", + "tensor-parallel-size": 2, + "ulysses-degree": 2, + "vae-patch-parallel-size": 4, + "vae-use-tiling": true, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "dataset": "random", + "task": "t2i", + "backend": "vllm-omni", + "duration_sec": 86400, + "max_concurrency": 1, + "num_prompts_per_batch": 50, + "width": { + "min": 512, + "max": 1024 + }, + "height": { + "min": 512, + "max": 1024 + }, + "num_inference_steps": 50, + "enable_negative_prompt": true + } + ] } ] diff --git a/tests/dfx/stability/tests/test_hunyuanvideo15.json b/tests/dfx/stability/tests/test_hunyuanvideo15.json new file mode 100644 index 000000000..1da2feec3 --- /dev/null +++ b/tests/dfx/stability/tests/test_hunyuanvideo15.json @@ -0,0 +1,57 @@ +[ + { + "test_name": "test_hunyuanvideo15_i2v_cachedit_tp2_vae_patch2_stability", + "description": "HunyuanVideo-1.5 I2V 24h: CacheDiT + TP=2 + VAE patch parallel=2 + VAE tiling", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": ["H800"] + }, + "num_cards": 2 + } + }, + "local_model", + "slow", + "diffusion" + ], + "server_params": { + "model": "hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v", + "serve_args": { + "flow-shift": 5.0, + "cache-backend": "cache_dit", + "tensor-parallel-size": 2, + "vae-patch-parallel-size": 2, + "vae-use-tiling": true, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "dataset": "random", + "task": "i2v", + "backend": "v1/videos", + "duration_sec": 86400, + "max_concurrency": 1, + "num_prompts_per_batch": 50, + "num_input_images": 1, + "seed": 42, + "enable_negative_prompt": true, + "random_request_config": [ + { + "width": 832, + "height": 480, + "num_inference_steps": 4, + "num_frames": 33, + "fps": 24, + "weight": 1 + } + ], + "extra_body": { + "guidance_scale": 6.0, + "flow_shift": 5.0 + } + } + ] + } +] diff --git a/tests/dfx/stability/tests/test_minicpmo_4_5.json b/tests/dfx/stability/tests/test_minicpmo_4_5.json new file mode 100644 index 000000000..eaf38ec1d --- /dev/null +++ b/tests/dfx/stability/tests/test_minicpmo_4_5.json @@ -0,0 +1,53 @@ +[ + { + "test_name": "test_minicpmo_4_5_stability_ready", + "description": "MiniCPM-o-4.5 ready deploy + seed-tts chat/audio stability (from perf test_minicpmo_4_5_ready)", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H800" + }, + "num_cards": 1 + } + }, + "local_model", + "slow", + "omni" + ], + "server_params": { + "model": "openbmb/MiniCPM-o-4_5", + "extra_cli_args": [ + "--deploy-config", + "vllm_omni/deploy/minicpmo_4_5.yaml", + "--trust-remote-code" + ] + }, + "benchmark_params": [ + { + "dataset_name": "seed-tts", + "dataset_path": "zhaochenyang20/seed-tts-eval", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "duration_sec": 86400, + "max_concurrency": 1, + "num_prompts_per_batch": 50, + "no_oversample": true, + "disable_shuffle": true, + "trust_remote_code": true, + "seed_tts_locale": "en", + "extra_body": { + "modalities": [ + "text", + "audio" + ], + "chat_template_kwargs": { + "enable_thinking": false, + "use_tts_template": true + } + }, + "percentile-metrics": "ttft,e2el,audio_ttfp,audio_rtf,audio_duration" + } + ] + } +] diff --git a/tests/dfx/stability/tests/test_minimax_h3.json b/tests/dfx/stability/tests/test_minimax_h3.json new file mode 100644 index 000000000..d671c48fd --- /dev/null +++ b/tests/dfx/stability/tests/test_minimax_h3.json @@ -0,0 +1,62 @@ +[ + { + "test_name": "test_minimax_h3_t2v_stability_4gpu", + "description": "MiniMax-H3 FastH3 T2VA on 4 GPUs (USP4 + HSDP4, encoder-TP4, VAE-PP4 tile); fused lora-path from VLLM_TEST_MINIMAX_H3_FASTH3_LORA; requests must use num_inference_steps=4", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H800" + }, + "num_cards": 4 + } + }, + "local_model", + "slow", + "diffusion" + ], + "server_params": { + "model": "MiniMaxAI/MiniMax-H3", + "serve_args": { + "trust-remote-code": true, + "task-type": "fl2va", + "num-gpus": 4, + "usp": 4, + "ring": 1, + "use-hsdp": true, + "hsdp-shard-size": 4, + "text-encoder-tp-size": 4, + "vae-patch-parallel-size": 4, + "vae-parallel-mode": "tile", + "vae-use-tiling": true, + "stage-init-timeout": 1800, + "init-timeout": 1800 + } + }, + "benchmark_params": [ + { + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "duration_sec": 86400, + "max_concurrency": 1, + "num_prompts_per_batch": 50, + "seed": 42, + "random_request_config": [ + { + "width": 1344, + "height": 768, + "prompt": "Cinematic drone shot pushing through misty mountain ridges at sunrise, golden light catching the clouds, slow and smooth.", + "num_inference_steps": 4, + "num_frames": 209, + "fps": 24, + "weight": 1 + } + ], + "extra_body": { + "extra_params": "{\"task\":\"t2va\",\"duration\":8.7,\"aspect_ratio\":\"16:9\"}" + } + } + ] + } +] diff --git a/tests/dfx/stability/tests/test_qwen3_omni.json b/tests/dfx/stability/tests/test_qwen3_omni.json index 816e981b6..bc2f7dacd 100644 --- a/tests/dfx/stability/tests/test_qwen3_omni.json +++ b/tests/dfx/stability/tests/test_qwen3_omni.json @@ -5,12 +5,14 @@ { "hardware_marks": { "res": { - "cuda": "H800" + "cuda": "H800", + "npu": "A3" }, "num_cards": 2 } }, "local_model", + "slow", "omni" ], "server_params": { diff --git a/tests/dfx/stability/tests/test_qwen3_tts.json b/tests/dfx/stability/tests/test_qwen3_tts.json index 1c9356574..37b7cca52 100644 --- a/tests/dfx/stability/tests/test_qwen3_tts.json +++ b/tests/dfx/stability/tests/test_qwen3_tts.json @@ -12,6 +12,7 @@ } }, "local_model", + "slow", "tts" ], "server_params": { diff --git a/tests/dfx/stability/tests/test_qwen_image.json b/tests/dfx/stability/tests/test_qwen_image.json index 0c21d0ac1..9756f1299 100644 --- a/tests/dfx/stability/tests/test_qwen_image.json +++ b/tests/dfx/stability/tests/test_qwen_image.json @@ -11,6 +11,7 @@ } }, "local_model", + "slow", "diffusion" ], "server_params": { @@ -36,5 +37,44 @@ "enable_negative_prompt": true } ] + }, + { + "test_name": "test_qwen_image_stability_a3", + "mark": [ + { + "hardware_marks": { + "res": { + "npu": "A3" + }, + "num_cards": 1 + } + }, + "local_model", + "slow", + "diffusion" + ], + "server_params": { + "model": "Qwen/Qwen-Image" + }, + "benchmark_params": [ + { + "dataset": "random", + "task": "t2i", + "backend": "vllm-omni", + "duration_sec": 86400, + "max_concurrency": 1, + "num_prompts_per_batch": 50, + "width": { + "min": 512, + "max": 1000 + }, + "height": { + "min": 512, + "max": 1000 + }, + "num_inference_steps": 50, + "enable_negative_prompt": true + } + ] } ] diff --git a/tests/dfx/stability/tests/test_wan22.json b/tests/dfx/stability/tests/test_wan22.json index e6ab0b5cf..de6627de4 100644 --- a/tests/dfx/stability/tests/test_wan22.json +++ b/tests/dfx/stability/tests/test_wan22.json @@ -12,6 +12,7 @@ } }, "local_model", + "slow", "diffusion" ], "server_params": { @@ -54,6 +55,7 @@ } }, "local_model", + "slow", "diffusion" ], "server_params": { diff --git a/tools/nightly/run_nightly_jobs.sh b/tools/nightly/run_nightly_jobs.sh index c1cca73e2..8a0809726 100644 --- a/tools/nightly/run_nightly_jobs.sh +++ b/tools/nightly/run_nightly_jobs.sh @@ -47,7 +47,10 @@ # From repo root: pytest -s -v --run-level full_model -m "" tests/dfx/stability/scripts/... # -m comes from --pytest-mark / PYTEST_MARK when set (e.g. H800 or A3). # Otherwise -m defaults to H800. PYTEST_MARK is rejected unless stability is enabled. -# model_type: omni → qwen3_omni; tts → qwen3_tts; diffusion → qwen_image + wan22 + hunyuan_image; all → all five +# model_type: omni → qwen3_omni + minicpmo_4_5; tts → qwen3_tts; +# diffusion → qwen_image + wan22 + hunyuan_image + minimax_h3 + hunyuanvideo15; all → all eight +# stability_minimax_h3: download FastH3 LoRA only when VLLM_TEST_MINIMAX_H3_FASTH3_LORA is unset, +# then export before pytest (collection skipif needs the path). # LABEL_SUBSTR: if set, script path / job key / filename must contain it # # Requirements: bash, python3, PyYAML (pip install pyyaml) @@ -596,11 +599,14 @@ def _write_job_timeouts_manifest(jobs_dir: Path, job_timeouts: dict[str, int]) - # Fixed stability scripts (when stability is selected); MODEL_TYPES narrows which run. STABILITY_CASES: list[tuple[str, str, tuple[str, ...]]] = [ - ("stability_qwen3_omni", "tests/dfx/stability/scripts/test_stability_qwen3_omni.py", ("omni",)), - ("stability_qwen3_tts", "tests/dfx/stability/scripts/test_stability_qwen3_tts.py", ("tts",)), - ("stability_qwen_image", "tests/dfx/stability/scripts/test_stability_qwen_image.py", ("diffusion",)), - ("stability_wan22", "tests/dfx/stability/scripts/test_stability_wan22.py", ("diffusion",)), - ("stability_hunyuan_image", "tests/dfx/stability/scripts/test_stability_hunyuan_image.py", ("diffusion",)), + ("stability_qwen3_omni", "tests/dfx/stability/scripts/run_stability_qwen3_omni.py", ("omni",)), + ("stability_qwen3_tts", "tests/dfx/stability/scripts/run_stability_qwen3_tts.py", ("tts",)), + ("stability_minicpmo_4_5", "tests/dfx/stability/scripts/run_stability_minicpmo_4_5.py", ("omni",)), + ("stability_qwen_image", "tests/dfx/stability/scripts/run_stability_qwen_image.py", ("diffusion",)), + ("stability_wan22", "tests/dfx/stability/scripts/run_stability_wan22.py", ("diffusion",)), + ("stability_hunyuan_image", "tests/dfx/stability/scripts/run_stability_hunyuan_image.py", ("diffusion",)), + ("stability_minimax_h3", "tests/dfx/stability/scripts/run_stability_minimax_h3.py", ("diffusion",)), + ("stability_hunyuanvideo15", "tests/dfx/stability/scripts/run_stability_hunyuanvideo15.py", ("diffusion",)), ] @@ -612,6 +618,25 @@ def _stability_model_matches(model_types: list[str], families: tuple[str, ...]) STABILITY_DEFAULT_PYTEST_MARK = "H800" +# Extra shell lines before pytest. MiniMax-H3 FastH3 skipif reads +# VLLM_TEST_MINIMAX_H3_FASTH3_LORA at collection; merge CI downloads the same way. +_FASTH3_LORA_DOWNLOAD = ( + 'VLLM_TEST_MINIMAX_H3_FASTH3_LORA="$(python3 -c ' + "'from huggingface_hub import hf_hub_download; " + "print(hf_hub_download(repo_id=\"FastVideo/FastVideo-FastH3-4-step-Preview-v1-LoRA\", " + "filename=\"dense-datafree/adapter_model.safetensors\"))')\"" +) +STABILITY_JOB_PRELUDE: dict[str, list[str]] = { + "stability_minimax_h3": [ + "if [ -f vllm_omni/diffusion/models/minimax_h3/fasth3.py ]; then", + ' if [ -z "${VLLM_TEST_MINIMAX_H3_FASTH3_LORA:-}" ]; then', + f" {_FASTH3_LORA_DOWNLOAD}", + " fi", + " export VLLM_TEST_MINIMAX_H3_FASTH3_LORA", + "fi", + ], +} + def _shell_quote_pytest_mark(marker_expr: str) -> str: """Shell-safe pytest ``-m`` value for generated bash job scripts.""" @@ -839,6 +864,7 @@ def run_stability_mode(jobs_dir: Path, model_types: list[str]) -> int: + (" (--pytest-mark)" if mark_from_cli else f" (default {STABILITY_DEFAULT_PYTEST_MARK})"), "set -euo pipefail", f'cd "{REPO_ROOT}"', + *STABILITY_JOB_PRELUDE.get(key, []), pytest_line, ] _write_job_script(key, script_lines, jobs_dir) From e5c0afc99432dba767dcb66f28b0fb9aa43ab1af Mon Sep 17 00:00:00 2001 From: tsinghua-code Date: Mon, 21 Sep 2026 20:21:46 +0800 Subject: [PATCH 07/21] [Model] Enable online FP8 linears for LingBot World (#7549) Signed-off-by: tsinghua-code --- recipes/Robbyant/LingBot-World-2.0.md | 43 +++++++++++++++- .../test_lingbot_world_attention.py | 4 ++ .../test_lingbot_world_transformer.py | 51 +++++++++++++++++-- .../test_pipeline_lingbot_world.py | 9 ++-- tests/engine/test_arg_utils.py | 12 ++--- .../models/lingbot_world/pipeline.py | 2 - .../models/lingbot_world/transformer.py | 32 +++++++++--- 7 files changed, 129 insertions(+), 24 deletions(-) diff --git a/recipes/Robbyant/LingBot-World-2.0.md b/recipes/Robbyant/LingBot-World-2.0.md index 7add2f7d8..b20796726 100644 --- a/recipes/Robbyant/LingBot-World-2.0.md +++ b/recipes/Robbyant/LingBot-World-2.0.md @@ -213,6 +213,47 @@ accepts: `s`, `d`, `i`, `j`, `k`, and `l`; - `lingbot.camera_trajectory.v1` for explicit pose/intrinsics trajectories. +## Online FP8 linear layers (experimental) + +Pass `quantization_config={"method": "fp8"}` to the existing `AsyncOmni` +construction to use vLLM's online FP8 linear implementation. On the tested +Hopper-class GPU this dispatches to CUTLASS FP8 GEMM with online per-tensor +activation scaling; no LingBot-specific quantization kernel is introduced. + +Only the transformer's existing vLLM parallel linear layers are eligible: +self-attention, cross-attention, FFN, camera injectors, and C2WS projections. +The ordinary PyTorch linear layers (including the output head and +time/text embeddings), normalization, convolutions, VAE, text encoder, and +AR KV cache are not converted to FP8 by this option. + +Quality-sensitive projections can remain BF16 through fully qualified +`ignored_layers` names. For example, the following configuration tests +retaining the camera/C2WS path in BF16: + +```python +quantization_config = { + "method": "fp8", + "ignored_layers": [ + f"transformer.blocks.{i}.cam_injector_layer{j}" + for i in range(40) + for j in (1, 2) + ] + + [ + "transformer.c2ws_hidden_states_layer1", + "transformer.c2ws_hidden_states_layer2", + ], +} +``` + +Use `transformer.blocks..self_attn.qkv` to exclude the fused Q/K/V +projection as a unit. The example exclusion list is an ablation, not a +universal quality guarantee or a default. Validate the intended scene, seed, +camera trajectory, and session length against BF16 before selecting a policy. +An FP8 kernel speedup alone is not an end-to-end performance claim. +The long-session E8 quality gate in +[#7074](https://github.com/vllm-project/vllm-omni/issues/7074) remains required +for Tier-2 acceptance. + ## Validation Real-checkpoint validation uses 480x832 output, four DMD steps, and seed 42. @@ -246,7 +287,7 @@ tested commit. AR blocks in one request. `max_num_seqs` must be one in both cases. - Stateful streaming VAE decode is not implemented; the realtime example emits latent chunks. -- SP/USP, pipeline/CFG parallelism, HSDP, VAE parallelism, quantization, +- SP/USP, pipeline/CFG parallelism, HSDP, VAE parallelism, quantization methods other than online FP8, Cache-DiT, TeaCache, causal-pretrain, and the 1.3B checkpoint are not claimed. - No AMD GPU, Ascend NPU, or Intel GPU support is claimed. diff --git a/tests/diffusion/models/lingbot_world/test_lingbot_world_attention.py b/tests/diffusion/models/lingbot_world/test_lingbot_world_attention.py index 7e12b3db3..d2d11538c 100644 --- a/tests/diffusion/models/lingbot_world/test_lingbot_world_attention.py +++ b/tests/diffusion/models/lingbot_world/test_lingbot_world_attention.py @@ -134,6 +134,8 @@ def _install_vllm_stubs() -> None: **kwargs, ) -> None: super().__init__() + self.quant_config = kwargs.pop("quant_config", None) + self.prefix = kwargs.pop("prefix", "") del kwargs self.return_bias = return_bias self.weight = nn.Parameter(torch.empty(output_size, input_size)) @@ -164,6 +166,8 @@ def _install_vllm_stubs() -> None: **kwargs, ) -> None: super().__init__() + self.quant_config = kwargs.pop("quant_config", None) + self.prefix = kwargs.pop("prefix", "") del kwargs self.num_heads = total_num_heads self.num_kv_heads = total_num_kv_heads or total_num_heads diff --git a/tests/diffusion/models/lingbot_world/test_lingbot_world_transformer.py b/tests/diffusion/models/lingbot_world/test_lingbot_world_transformer.py index e11c34e35..7087e6171 100644 --- a/tests/diffusion/models/lingbot_world/test_lingbot_world_transformer.py +++ b/tests/diffusion/models/lingbot_world/test_lingbot_world_transformer.py @@ -35,6 +35,8 @@ def _tiny_model( num_layers: int = 2, num_frames_per_block: int = 1, sliding_window_num_frames: int = 3, + quant_config=None, + prefix: str = "", attention_head_dim: int = 2, rope_max_seq_len: int = 16, ): @@ -55,6 +57,8 @@ def _tiny_model( num_frames_per_block=num_frames_per_block, sliding_window_num_frames=sliding_window_num_frames, local_attn_size=-1, + quant_config=quant_config, + prefix=prefix, ) @@ -485,11 +489,52 @@ def test_constructor_rejects_non_null_image_embedding_fields(field: str) -> None @pytest.mark.cpu -def test_constructor_rejects_unsupported_quantization_with_runtime_error() -> None: +@pytest.mark.parametrize("prefix", ["", "transformer"]) +@pytest.mark.parametrize("quantized", [False, True]) +def test_constructor_propagates_quantization_to_parallel_linears(prefix: str, quantized: bool) -> None: module = attention_tests._load_module() + quant_config = object() if quantized else None + model = _tiny_model(module, num_layers=1, quant_config=quant_config, prefix=prefix) + block = model.blocks[0] + quantized_linears = [ + model.c2ws_hidden_states_layer1, + model.c2ws_hidden_states_layer2, + block.self_attn.qkv, + block.self_attn.o, + block.cross_attn.q, + block.cross_attn.k, + block.cross_attn.v, + block.cross_attn.o, + block.ffn[0], + block.ffn[2], + block.cam_injector_layer1, + block.cam_injector_layer2, + ] - with pytest.raises(RuntimeError, match="quant_config.*not supported"): - module.CausalLingBotWorldTransformer3DModel(quant_config=object()) + assert len(quantized_linears) == 12 + assert all(layer.quant_config is quant_config for layer in quantized_linears) + expected_names = { + "c2ws_hidden_states_layer1", + "c2ws_hidden_states_layer2", + *( + f"blocks.0.{name}" + for name in ( + "self_attn.qkv", + "self_attn.o", + "cross_attn.q", + "cross_attn.k", + "cross_attn.v", + "cross_attn.o", + "ffn.0", + "ffn.2", + "cam_injector_layer1", + "cam_injector_layer2", + ) + ), + } + assert {layer.prefix for layer in quantized_linears} == { + f"{prefix}.{name}" if prefix else name for name in expected_names + } @pytest.mark.cpu diff --git a/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py b/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py index b5a57ce75..8c24b8562 100644 --- a/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py +++ b/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py @@ -889,13 +889,14 @@ def test_unsupported_sp_config_fails_before_component_loading(overrides): assert module._loader_state.prefetch_calls == [] -def test_unsupported_quantization_fails_before_component_loading() -> None: +def test_quantization_reaches_transformer_factory() -> None: module = _load_pipeline_module() + quant_config = object() - with pytest.raises(NotImplementedError, match="quantization"): - module.LingBotWorldCausalDMDPipeline(od_config=_od_config(quantization_config=object())) + pipeline = module.LingBotWorldCausalDMDPipeline(od_config=_od_config(quantization_config=quant_config)) - assert module._loader_state.prefetch_calls == [] + assert pipeline.transformer is not None + assert _FakeTransformerFactory.last_call[1:] == (quant_config, "transformer") def test_official_scheduler_config_matches_fixed_dmd_contract() -> None: diff --git a/tests/engine/test_arg_utils.py b/tests/engine/test_arg_utils.py index d40a9c226..3b0192a79 100644 --- a/tests/engine/test_arg_utils.py +++ b/tests/engine/test_arg_utils.py @@ -563,19 +563,19 @@ def test_from_cli_args_preserves_text_encoder_tp_size(): def test_text_encoder_tp_size_reaches_default_diffusion_parallel_config(): """The preserved CLI value must land in DiffusionParallelConfig. - Mirrors the library flow: from_cli_args -> kwargs -> the generic - diffusion fallback, which resolves the field through - ``DiffusionParallelConfig.from_stage_overrides``. + Forward the preserved explicit override to the generic diffusion + fallback, which resolves it through + ``DiffusionParallelConfig.from_stage_overrides``. Serializing all engine + defaults would also forward unrelated LLM-only fields to strict diffusion + ingress, unlike the explicit-kwargs library entrypoint. """ - from dataclasses import asdict - from vllm_omni.config.config_factory import StageConfigFactory engine_args = OmniEngineArgs.from_cli_args( SimpleNamespace(text_encoder_tp_size=2), ) stage_cfg = StageConfigFactory.create_default_diffusion( - {k: v for k, v in asdict(engine_args).items() if v is not None}, + {"text_encoder_tp_size": engine_args.text_encoder_tp_size}, )[0] parallel_config = stage_cfg["engine_args"]["parallel_config"] diff --git a/vllm_omni/diffusion/models/lingbot_world/pipeline.py b/vllm_omni/diffusion/models/lingbot_world/pipeline.py index 8bb83ef80..fec175e69 100644 --- a/vllm_omni/diffusion/models/lingbot_world/pipeline.py +++ b/vllm_omni/diffusion/models/lingbot_world/pipeline.py @@ -241,8 +241,6 @@ def _validate_scheduler_config(config: dict[str, Any]) -> None: def _validate_parallel_config(od_config: OmniDiffusionConfig) -> None: - if getattr(od_config, "quantization_config", None) is not None: - raise NotImplementedError("LingBot World v1 does not support quantization.") parallel_config = getattr(od_config, "parallel_config", None) if parallel_config is None: return diff --git a/vllm_omni/diffusion/models/lingbot_world/transformer.py b/vllm_omni/diffusion/models/lingbot_world/transformer.py index 9a270e611..5b36299d8 100644 --- a/vllm_omni/diffusion/models/lingbot_world/transformer.py +++ b/vllm_omni/diffusion/models/lingbot_world/transformer.py @@ -8,7 +8,7 @@ import math from collections.abc import Iterable from dataclasses import dataclass from types import SimpleNamespace -from typing import Any, Self, cast +from typing import TYPE_CHECKING, Any, Self, cast import torch import torch.nn as nn @@ -36,6 +36,9 @@ from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import ( paged_write_attn, ) +if TYPE_CHECKING: + from vllm.model_executor.layers.quantization.base_config import QuantizationConfig + @dataclass class LingBotAttentionCache: @@ -170,6 +173,7 @@ class LingBotSelfAttention(nn.Module): num_heads: int, *, eps: float = 1e-6, + quant_config: QuantizationConfig | None = None, prefix: str = "", ) -> None: super().__init__() @@ -188,6 +192,7 @@ class LingBotSelfAttention(nn.Module): head_size=self.head_dim, total_num_heads=num_heads, bias=True, + quant_config=quant_config, prefix=_projection_prefix(prefix, "qkv"), ) self.num_local_heads = self.qkv.num_heads @@ -205,6 +210,7 @@ class LingBotSelfAttention(nn.Module): bias=True, input_is_parallel=True, return_bias=False, + quant_config=quant_config, prefix=_projection_prefix(prefix, "o"), ) self.norm_q = _LingBotRMSNorm(self.tp_inner_dim, eps) @@ -416,6 +422,7 @@ class LingBotCrossAttention(nn.Module): num_heads: int, *, eps: float = 1e-6, + quant_config: QuantizationConfig | None = None, prefix: str = "", ) -> None: super().__init__() @@ -443,6 +450,7 @@ class LingBotCrossAttention(nn.Module): bias=True, gather_output=False, return_bias=False, + quant_config=quant_config, prefix=_projection_prefix(prefix, "q"), ) self.k = ColumnParallelLinear( @@ -451,6 +459,7 @@ class LingBotCrossAttention(nn.Module): bias=True, gather_output=False, return_bias=False, + quant_config=quant_config, prefix=_projection_prefix(prefix, "k"), ) self.v = ColumnParallelLinear( @@ -459,6 +468,7 @@ class LingBotCrossAttention(nn.Module): bias=True, gather_output=False, return_bias=False, + quant_config=quant_config, prefix=_projection_prefix(prefix, "v"), ) self.o = RowParallelLinear( @@ -467,6 +477,7 @@ class LingBotCrossAttention(nn.Module): bias=True, input_is_parallel=True, return_bias=False, + quant_config=quant_config, prefix=_projection_prefix(prefix, "o"), ) self.norm_q = _LingBotRMSNorm(self.tp_inner_dim, eps) @@ -528,6 +539,7 @@ class LingBotAttentionBlock(nn.Module): ffn_dim: int | None = None, cross_attn_norm: bool = True, eps: float = 1e-6, + quant_config: QuantizationConfig | None = None, prefix: str = "", ) -> None: super().__init__() @@ -538,12 +550,14 @@ class LingBotAttentionBlock(nn.Module): dim, num_heads, eps=eps, + quant_config=quant_config, prefix=_projection_prefix(prefix, "self_attn"), ) self.cross_attn = LingBotCrossAttention( dim, num_heads, eps=eps, + quant_config=quant_config, prefix=_projection_prefix(prefix, "cross_attn"), ) self.norm2 = LayerNorm(dim, eps=eps, elementwise_affine=False) @@ -555,6 +569,7 @@ class LingBotAttentionBlock(nn.Module): bias=True, gather_output=False, return_bias=False, + quant_config=quant_config, prefix=_projection_prefix(prefix, "ffn.0"), ), nn.GELU(approximate="tanh"), @@ -564,6 +579,7 @@ class LingBotAttentionBlock(nn.Module): bias=True, input_is_parallel=True, return_bias=False, + quant_config=quant_config, prefix=_projection_prefix(prefix, "ffn.2"), ), ) @@ -574,6 +590,7 @@ class LingBotAttentionBlock(nn.Module): bias=True, gather_output=False, return_bias=False, + quant_config=quant_config, prefix=_projection_prefix(prefix, "cam_injector_layer1"), ) self.cam_injector_layer2 = RowParallelLinear( @@ -582,6 +599,7 @@ class LingBotAttentionBlock(nn.Module): bias=True, input_is_parallel=True, return_bias=False, + quant_config=quant_config, prefix=_projection_prefix(prefix, "cam_injector_layer2"), ) self.cam_scale_layer = nn.Linear(dim, dim) @@ -823,7 +841,7 @@ class CausalLingBotWorldTransformer3DModel(nn.Module): num_frames_per_block: int = 3, sliding_window_num_frames: int = 18, local_attn_size: int = -1, - quant_config: object | None = None, + quant_config: QuantizationConfig | None = None, prefix: str = "", ) -> None: super().__init__() @@ -853,11 +871,6 @@ class CausalLingBotWorldTransformer3DModel(nn.Module): ): if field_value is not None: raise ValueError(f"{field_name} must be None because LingBot World v2 has no image embedding path.") - if quant_config is not None: - raise RuntimeError( - "quant_config is not supported by the LingBot World transformer; construct the unquantized model." - ) - dim = num_attention_heads * attention_head_dim self.dim = dim self.config = SimpleNamespace( @@ -898,6 +911,7 @@ class CausalLingBotWorldTransformer3DModel(nn.Module): bias=True, gather_output=False, return_bias=False, + quant_config=quant_config, prefix=_projection_prefix(prefix, "c2ws_hidden_states_layer1"), ) self.c2ws_hidden_states_layer2 = RowParallelLinear( @@ -906,6 +920,7 @@ class CausalLingBotWorldTransformer3DModel(nn.Module): bias=True, input_is_parallel=True, return_bias=False, + quant_config=quant_config, prefix=_projection_prefix(prefix, "c2ws_hidden_states_layer2"), ) self.text_embedding = nn.Sequential( @@ -930,6 +945,7 @@ class CausalLingBotWorldTransformer3DModel(nn.Module): ffn_dim=ffn_dim, cross_attn_norm=cross_attn_norm, eps=eps, + quant_config=quant_config, prefix=_projection_prefix(prefix, f"blocks.{index}"), ) for index in range(num_layers) @@ -976,7 +992,7 @@ class CausalLingBotWorldTransformer3DModel(nn.Module): cls, config: dict[str, Any], *, - quant_config: Any | None = None, + quant_config: QuantizationConfig | None = None, prefix: str = "", ) -> Self: checkpoint_contract = { From fa1d03ab8d5111b81968e2173bf61981c43aa0c7 Mon Sep 17 00:00:00 2001 From: Weiming Liao Date: Mon, 21 Sep 2026 21:07:21 +0800 Subject: [PATCH 08/21] [Frontend] Add MiniMax-H3 latent editing workflows (WF-05) (#7898) Signed-off-by: chen hongwei <1792043268@qq.com> Signed-off-by: Weiming Liao Co-authored-by: chen hongwei <1792043268@qq.com> Co-authored-by: chen hongwei <54873389+avicii-forever@users.noreply.github.com> --- apps/ComfyUI-vLLM-Omni/README.md | 15 +- apps/ComfyUI-vLLM-Omni/__init__.py | 6 + .../comfyui_vllm_omni/nodes.py | 92 + .../comfyui_vllm_omni/utils/api_client.py | 49 + .../comfyui_vllm_omni/utils/latent_mask.py | 51 + .../comfyui_vllm_omni/utils/types.py | 4 + .../docs/wf05-h3-latent-editing.md | 17 + ...Omni Latent Mask Editing - Image Mask.json | 499 ++ ...i Latent Mask Editing - Temporal Mask.json | 560 ++ .../vLLM-Omni Latent Mask Editing.json | 511 ++ ...M-Omni MiniMax-H3 Latent Mask Editing.json | 5241 +++++++++++++++++ apps/ComfyUI-vLLM-Omni/web/main.js | 1 + .../features/comfyui/mock_videos_server.py | 115 + .../e2e/features/comfyui/test_latent_mask.py | 80 + .../comfyui/test_latent_mask_editing.py | 99 + 15 files changed, 7339 insertions(+), 1 deletion(-) create mode 100644 apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/latent_mask.py create mode 100644 apps/ComfyUI-vLLM-Omni/docs/wf05-h3-latent-editing.md create mode 100644 apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Latent Mask Editing - Image Mask.json create mode 100644 apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Latent Mask Editing - Temporal Mask.json create mode 100644 apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Latent Mask Editing.json create mode 100644 apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni MiniMax-H3 Latent Mask Editing.json create mode 100644 tests/e2e/features/comfyui/mock_videos_server.py create mode 100644 tests/e2e/features/comfyui/test_latent_mask.py create mode 100644 tests/e2e/features/comfyui/test_latent_mask_editing.py diff --git a/apps/ComfyUI-vLLM-Omni/README.md b/apps/ComfyUI-vLLM-Omni/README.md index f33e26b40..c26b2a3de 100644 --- a/apps/ComfyUI-vLLM-Omni/README.md +++ b/apps/ComfyUI-vLLM-Omni/README.md @@ -49,6 +49,7 @@ This extension offers the following nodes based on the output modalities (at **C - **Generate Image** for text-to-image and image-to-image tasks - **Generate Video** for text-to-video, first-frame/image-to-video, and reference-conditioned video +- **Latent Mask Editing** for MiniMax-H3 latent-mask editing (source media plus video/audio noise masks) - **FastH3 Deployment** for routing text-to-video requests to a MiniMax-H3 server with FastH3 fused at startup - **Multimodality Understanding** for multimodality-to-text and multimodality-to-audio tasks - **TTS** and **TTS Voice Clone** for TTS tasks @@ -67,7 +68,7 @@ Every node carries the vLLM-Omni mark in its title bar and is tinted by what it | Amber | AR / Diffusion / Multi-Stage Sampling Params | Sampling parameters that apply to any model | | Purple | Qwen TTS Params, Wan Video Params, MiniMax-H3 Video Params | Parameters that only one model family accepts | | Red | LoRA, FastH3 Deployment | Which weights the server is expected to have loaded | -| Teal | Video References | Reference media | +| Teal | Video References, Latent Mask Editing | Reference media | Recolouring a node by hand (right click -> Colors) overrides its tint, and the choice is kept. @@ -263,6 +264,18 @@ Open the **vLLM-Omni FastH3 Text to Video** template, then: The node records which server the workflow targets; it does not start one, nor switch adapters or attention backends on a running server. +#### Latent-mask editing (MiniMax-H3) + +The [WF-05 template](example_workflows/vLLM-Omni%20MiniMax-H3%20Latent%20Mask%20Editing.json) contains inpainting, object removal, continuation, and extension examples in one graph. See the [workflow guide](docs/wf05-h3-latent-editing.md) for inputs, mask settings, dependencies, and preview limitations. + +Connect a **Latent Mask Editing** node to **Generate Video → latent_edit** to edit a source clip instead of generating from scratch. It uploads the source media and serializes the video/audio noise masks the MiniMax H3 API accepts: + +- `source_video` / `source_audio` — the media to edit. +- `video_mask` — a ComfyUI mask image; `0` preserves a region, `1` regenerates it, fractional values blend. A 2D mask `[H, W]` is applied to every frame; a 3D mask `[T, H, W]` is treated as a temporal mask (one slice per frame) for continuation or extension. It is resized to the video latent grid. +- `audio_mask` — a scalar in `[0, 1]`; `0` keeps the source audio, `1` regenerates it, fractional values blend. + +A non-trivial mask requires its matching source, and a source without a mask is rejected. This node only forwards inputs to the server; the served model must declare latent-mask editing support (MiniMax-H3). + ### TTS (e.g., Qwen TTS series) (Also available at **ComfyUI sidebar->Template->vLLM-Omni->vLLM-Omni TTS**) diff --git a/apps/ComfyUI-vLLM-Omni/__init__.py b/apps/ComfyUI-vLLM-Omni/__init__.py index 8c1b03d87..999f85ab2 100644 --- a/apps/ComfyUI-vLLM-Omni/__init__.py +++ b/apps/ComfyUI-vLLM-Omni/__init__.py @@ -21,7 +21,9 @@ from .comfyui_vllm_omni.nodes import ( VLLMOmniGenerateImage, VLLMOmniGenerateMusic, VLLMOmniGenerateVideo, + VLLMOmniLatentMaskEditing, VLLMOmniMiniMaxH3Params, + VLLMOmniMiniMaxH3TemporalMask, VLLMOmniQwenTTSParams, VLLMOmniRemoteLoRA, VLLMOmniSamplingParamsList, @@ -34,6 +36,7 @@ from .comfyui_vllm_omni.nodes import ( # A dictionary that contains all nodes you want to export with their names NODE_CLASS_MAPPINGS = { + "VLLMOmniMiniMaxH3TemporalMask": VLLMOmniMiniMaxH3TemporalMask, # === Generation === "VLLMOmniGenerateImage": VLLMOmniGenerateImage, "VLLMOmniGenerateVideo": VLLMOmniGenerateVideo, @@ -42,6 +45,7 @@ NODE_CLASS_MAPPINGS = { "VLLMOmniGenerateMusic": VLLMOmniGenerateMusic, "VLLMOmniVoiceClone": VLLMOmniVoiceClone, "VLLMOmniVideoReferences": VLLMOmniVideoReferences, + "VLLMOmniLatentMaskEditing": VLLMOmniLatentMaskEditing, # === Params === "VLLMOmniARSampling": VLLMOmniARSampling, "VLLMOmniDiffusionSampling": VLLMOmniDiffusionSampling, @@ -55,6 +59,7 @@ NODE_CLASS_MAPPINGS = { # A dictionary that contains the friendly/humanly readable titles for the nodes NODE_DISPLAY_NAME_MAPPINGS = { + "VLLMOmniMiniMaxH3TemporalMask": "MiniMax-H3 Temporal Mask", # === Generation === "VLLMOmniGenerateImage": "Generate Image", "VLLMOmniGenerateVideo": "Generate Video", @@ -63,6 +68,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "VLLMOmniGenerateMusic": "Generate Music", "VLLMOmniVoiceClone": "TTS Voice Cloning", "VLLMOmniVideoReferences": "Video References", + "VLLMOmniLatentMaskEditing": "Latent Mask Editing", # === Params === "VLLMOmniARSampling": "AR Sampling Params", "VLLMOmniDiffusionSampling": "Diffusion Sampling Params", diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py index 6d5006a11..5bddb9345 100644 --- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py +++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py @@ -1,12 +1,14 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +import math from typing import Literal import torch from comfy_api.input import AudioInput, VideoInput from .utils.api_client import VLLMOmniClient +from .utils.latent_mask import _align_frame_count, _video_latent_t from .utils.logger import get_logger from .utils.models import lookup_model_spec from .utils.types import ( @@ -17,6 +19,7 @@ from .utils.types import ( AutoregressionSamplingParams, DiffusionSamplingParams, FastH3Deployment, + LatentMaskEditing, MiniMaxH3ModelSpecificParams, QwenTTSModelSpecificParams, VideoReferences, @@ -210,6 +213,7 @@ class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase): "lora": ("REMOTE_LORA",), "model_params": ("VIDEO_PARAMS",), "fast_h3": ("FASTH3_DEPLOYMENT",), + "latent_edit": ("LATENT_MASK_EDITING",), }, } @@ -262,6 +266,7 @@ class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase): model_params: dict | None = None, lora: dict | None = None, fast_h3: dict | None = None, + latent_edit: dict | None = None, **kwargs, ): if kwargs: @@ -350,6 +355,7 @@ class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase): sampling_params=sampling_params, lora=lora, model_params=model_params, + latent_edit=latent_edit, ) return (output,) @@ -1051,3 +1057,89 @@ class VLLMOmniVideoReferences: if value is not None: refs[f"{kind}_{index}"] = value return (refs,) + + +class VLLMOmniLatentMaskEditing: + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "optional": { + "source_video": ("VIDEO",), + "source_audio": ("AUDIO",), + "video_mask": ("MASK",), + "audio_mask": ("FLOAT", {"default": -1.0, "min": -1.0, "max": 1.0, "step": 0.01}), + }, + } + + RETURN_TYPES = ("LATENT_MASK_EDITING",) + RETURN_NAMES = ("latent_edit",) + FUNCTION = "get_latent_edit" + CATEGORY = "vLLM-Omni" + + def get_latent_edit( + self, + source_video: VideoInput | None = None, + source_audio: AudioInput | None = None, + video_mask: torch.Tensor | None = None, + audio_mask: float = -1.0, + **kwargs, + ): + if kwargs: + logger.info("Uncaught kwargs: %s", kwargs) + edit = LatentMaskEditing() + if source_video is not None: + edit["source_video"] = source_video + if source_audio is not None: + edit["source_audio"] = source_audio + if video_mask is not None: + edit["video_mask"] = video_mask + if audio_mask >= 0.0: + edit["audio_mask"] = audio_mask + return (edit,) + + +class VLLMOmniMiniMaxH3TemporalMask: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "source_fps": ("FLOAT", {"default": 24.0, "min": 0.01}), + "duration": ("FLOAT", {"default": 5.0, "min": 0.01, "max": 15.0}), + "mode": (["continuation", "extension"],), + "preserve_fraction": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0}), + } + } + + RETURN_TYPES = ("MASK", "FLOAT", "IMAGE", "MASK") + RETURN_NAMES = ("mask", "preview_fps", "preview_images", "preview_mask") + FUNCTION = "build" + CATEGORY = "vLLM-Omni" + + def build(self, images, source_fps, duration, mode, preserve_fraction=0.5): + if images.shape[0] <= 0 or not math.isfinite(source_fps) or source_fps <= 0: + raise ValueError("Source must contain frames and have a positive finite FPS.") + if not math.isfinite(duration) or duration <= 0 or not 0 <= preserve_fraction <= 1: + raise ValueError("Invalid duration or preserve_fraction.") + frames = _align_frame_count(max(1, round(duration * 24))) + source_seconds = images.shape[0] / source_fps + if mode == "extension": + if frames / 24 <= source_seconds: + raise ValueError("Extension output must be longer than the source; increase duration.") + boundary = source_seconds + elif mode == "continuation": + boundary = min(source_seconds, frames / 24) * preserve_fraction + else: + raise ValueError(f"Unknown temporal mask mode: {mode}") + available = min(frames, math.floor(boundary * 24 + 1e-8)) + prefix = 0 if available < 5 else 5 + 17 * ((available - 5) // 17) + preserved = _video_latent_t(prefix) if prefix else 0 + total = _video_latent_t(frames) + mask = torch.ones(total, 1, 1) + mask[:preserved] = 0 + indices = torch.arange(frames, device=images.device) + source_indices = (indices * (source_fps / 24)).floor().long().clamp(max=images.shape[0] - 1) + preview_images = images.index_select(0, source_indices) + preview_mask = mask.index_select(0, torch.arange(frames) * total // frames) + return mask, 24.0, preview_images, preview_mask diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py index 21935414b..69e1af899 100644 --- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py +++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py @@ -30,6 +30,7 @@ from .format import ( video_to_base64, video_to_bytes, ) +from .latent_mask import scalar_mask_to_json, video_mask_to_grid_json from .logger import get_logger, pretty_printer from .models import lookup_model_spec from .types import ( @@ -283,6 +284,7 @@ class VLLMOmniClient: sampling_params: dict | None = None, model_params: dict | None = None, lora: dict | None = None, + latent_edit: dict | None = None, spec_model: str | None = None, **extra_params, ) -> VideoInput: @@ -386,6 +388,53 @@ class VLLMOmniClient: content_type="image/png", ) + # === latent-mask editing (MiniMax H3) === + if latent_edit is not None: + source_video = latent_edit.get("source_video") + source_audio = latent_edit.get("source_audio") + video_mask = latent_edit.get("video_mask") + audio_mask = latent_edit.get("audio_mask") + + if video_mask is None and audio_mask is None: + raise ValueError("Latent-mask editing requires at least one mask.") + + video_mask_trivial = video_mask is None or bool((video_mask == 1.0).all().item()) + audio_mask_trivial = audio_mask is None or audio_mask == 1.0 + if not video_mask_trivial and source_video is None: + raise ValueError("A non-trivial video mask requires a source video.") + if not audio_mask_trivial and source_audio is None and source_video is None: + raise ValueError("A non-trivial audio mask requires a source audio or a source video with audio.") + + if source_video is not None: + form.add_field( + "source_video", + video_to_bytes(source_video, "source.mp4"), + filename="source.mp4", + content_type="video/mp4", + ) + if source_audio is not None: + form.add_field( + "source_audio", + audio_to_bytes(source_audio, "source_audio.mp3"), + filename="source_audio.mp3", + content_type="audio/mpeg", + ) + if video_mask is not None: + mask_json = video_mask_to_grid_json(video_mask, width=width, height=height, num_frames=num_frames) + form.add_field( + "video_noise_mask", + mask_json.encode("utf-8"), + filename="video-mask.json", + content_type="application/json", + ) + if audio_mask is not None: + form.add_field( + "audio_noise_mask", + scalar_mask_to_json(audio_mask).encode("utf-8"), + filename="audio-mask.json", + content_type="application/json", + ) + if len(keyframe_images) == 2: for image_filename, image in keyframe_images: form.add_field( diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/latent_mask.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/latent_mask.py new file mode 100644 index 000000000..532cf8184 --- /dev/null +++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/latent_mask.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import json + +import torch +import torch.nn.functional as F + + +def scalar_mask_to_json(value: float) -> str: + if not 0.0 <= value <= 1.0: + raise ValueError(f"mask value must be in [0, 1], got {value}") + return str(value) + + +def _align_frame_count(frame_count: int) -> int: + if frame_count <= 0: + return 1 + current = int(frame_count) + while current % 17 != 5: + current += 1 + return current + + +def _video_latent_t(frame_count: int) -> int: + if frame_count <= 5: + return 2 + return ((int(frame_count) - 5) // 17) * 5 + 2 + + +def video_mask_to_grid(mask: torch.Tensor, *, width: int, height: int, num_frames: int) -> torch.Tensor: + tv = _video_latent_t(_align_frame_count(num_frames)) + height = int(height) // 32 * 32 + width = int(width) // 32 * 32 + gh, gw = height // 16, width // 16 + + if mask.ndim == 2: + mask = mask.unsqueeze(0) + elif mask.ndim != 3: + raise ValueError(f"expected a 2D or 3D mask tensor, got {mask.ndim}D") + + grid = F.interpolate(mask.unsqueeze(1).float(), size=(gh, gw), mode="area").squeeze(1) + if grid.shape[0] == 1: + grid = grid.expand(tv, gh, gw) + elif grid.shape[0] != tv: + grid = F.interpolate(grid.unsqueeze(0).unsqueeze(0), size=(tv, gh, gw), mode="nearest").squeeze(0).squeeze(0) + return grid + + +def video_mask_to_grid_json(mask: torch.Tensor, *, width: int, height: int, num_frames: int) -> str: + return json.dumps(video_mask_to_grid(mask, width=width, height=height, num_frames=num_frames).tolist()) diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py index 6d12a98c2..6194613de 100644 --- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py +++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py @@ -40,6 +40,10 @@ class VideoReferences(dict): pass +class LatentMaskEditing(dict): + pass + + class FastH3Deployment(dict): """Descriptor for a server that fused FastH3 at startup.""" diff --git a/apps/ComfyUI-vLLM-Omni/docs/wf05-h3-latent-editing.md b/apps/ComfyUI-vLLM-Omni/docs/wf05-h3-latent-editing.md new file mode 100644 index 000000000..9ed10273f --- /dev/null +++ b/apps/ComfyUI-vLLM-Omni/docs/wf05-h3-latent-editing.md @@ -0,0 +1,17 @@ +# WF-05: MiniMax-H3 latent-mask editing + +Edit a source video with MiniMax-H3 using spatial or temporal masks. The workflow includes object removal, inpainting, continuation, and extension, with a mask preview for each case. + +## Requirements + +- ComfyUI with ComfyUI-vLLM-Omni installed. +- An H3 service using the FL2VA checkpoint partition with latent-mask editing support. Follow the [H3 recipe](../../../recipes/MiniMaxAI/MiniMax-H3.md) for server setup. + +## Use + +Open **vLLM-Omni MiniMax-H3 Latent Mask Editing.json** from the example workflows and set the Generate Video URL/model for the case you want to run. + +1. Upload your source video in Load Video. +2. Adjust the case's mask. For removal and inpainting, set Mask Canvas to the video dimensions, Mask Rectangle to the region size, and Combine Masks x/y to its position. For continuation and extension, set MiniMax-H3 Temporal Mask's mode and duration; continuation also uses `preserve_fraction`. Match the duration in Generate Video, and use a duration longer than the source for extension. Mask values are `0` to preserve and `1` to regenerate. +3. Adjust the prompt to describe the desired content. Set the output dimensions and duration in Generate Video. If editing audio, update the sound description and audio mask (`0` preserves, `1` regenerates the whole clip). +4. Select the case's Mask Preview Save Video node and choose **Execute to selected output nodes** to preview the mask as a red overlay without inference, or select its Result Save Video node to generate the edited video. Running the entire workflow generates all four cases. diff --git a/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Latent Mask Editing - Image Mask.json b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Latent Mask Editing - Image Mask.json new file mode 100644 index 000000000..17be731bf --- /dev/null +++ b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Latent Mask Editing - Image Mask.json @@ -0,0 +1,499 @@ +{ + "id": "latent-mask-editing-7380", + "revision": 0, + "last_node_id": 7, + "last_link_id": 5, + "nodes": [ + { + "id": 1, + "type": "MarkdownNote", + "pos": [ + 40, + 20 + ], + "size": [ + 420, + 210 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Latent-mask editing server prerequisite", + "properties": {}, + "widgets_values": [ + "Requires a vLLM-Omni server that declares latent-mask editing support (MiniMax-H3, EDIT-01). The source clip and video/audio noise masks are uploaded to /v1/videos; 0 preserves, 1 regenerates, fractional blends." + ], + "color": "#432", + "bgcolor": "#000" + }, + { + "id": 2, + "type": "LoadVideo", + "pos": [ + 40, + 280 + ], + "size": [ + 420, + 120 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "file", + "name": "file", + "type": "COMBO", + "widget": { + "name": "file" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "LoadVideo" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 3, + "type": "LoadImageMask", + "pos": [ + 40, + 460 + ], + "size": [ + 420, + 160 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "STRING", + "widget": { + "name": "image" + }, + "link": null + }, + { + "name": "channel", + "type": "STRING", + "widget": { + "name": "channel" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "mask", + "name": "mask", + "type": "MASK", + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "LoadImageMask" + }, + "widgets_values": [ + "", + "red" + ] + }, + { + "id": 4, + "type": "VLLMOmniLatentMaskEditing", + "pos": [ + 560, + 260 + ], + "size": [ + 420, + 300 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "source_video", + "name": "source_video", + "type": "VIDEO", + "link": 1, + "shape": 7 + }, + { + "localized_name": "source_audio", + "name": "source_audio", + "type": "AUDIO", + "link": null + }, + { + "localized_name": "video_mask", + "name": "video_mask", + "type": "MASK", + "link": 2, + "shape": 7 + }, + { + "localized_name": "audio_mask", + "name": "audio_mask", + "type": "FLOAT", + "widget": { + "name": "audio_mask" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "latent_edit", + "name": "latent_edit", + "type": "LATENT_MASK_EDITING", + "links": [ + 3 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniLatentMaskEditing" + }, + "widgets_values": [ + 0.5 + ] + }, + { + "id": 5, + "type": "VLLMOmniMiniMaxH3Params", + "pos": [ + 40, + 680 + ], + "size": [ + 420, + 140 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "audio_flow_shift", + "name": "audio_flow_shift", + "type": "FLOAT", + "widget": { + "name": "audio_flow_shift" + }, + "link": null + }, + { + "localized_name": "flow_shift", + "name": "flow_shift", + "type": "FLOAT", + "widget": { + "name": "flow_shift" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video params", + "name": "video params", + "type": "VIDEO_PARAMS", + "links": [ + 4 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniMiniMaxH3Params" + }, + "widgets_values": [ + 3.0, + 12.0 + ] + }, + { + "id": 6, + "type": "VLLMOmniGenerateVideo", + "pos": [ + 1080, + 200 + ], + "size": [ + 540, + 680 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "frame", + "name": "frame", + "type": "IMAGE", + "link": null, + "shape": 7 + }, + { + "localized_name": "references", + "name": "references", + "type": "VIDEO_REFERENCES", + "link": null, + "shape": 7 + }, + { + "localized_name": "sampling_params", + "name": "sampling_params", + "type": "SAMPLING_PARAMS", + "link": null + }, + { + "localized_name": "lora", + "name": "lora", + "type": "REMOTE_LORA", + "link": null + }, + { + "localized_name": "model_params", + "name": "model_params", + "type": "VIDEO_PARAMS", + "link": 4, + "shape": 7 + }, + { + "localized_name": "fast_h3", + "name": "fast_h3", + "type": "FASTH3_DEPLOYMENT", + "link": null + }, + { + "localized_name": "latent_edit", + "name": "latent_edit", + "type": "LATENT_MASK_EDITING", + "link": 3, + "shape": 7 + }, + { + "localized_name": "url", + "name": "url", + "type": "STRING", + "widget": { + "name": "url" + }, + "link": null + }, + { + "localized_name": "model", + "name": "model", + "type": "STRING", + "widget": { + "name": "model" + }, + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": null + }, + { + "localized_name": "negative_prompt", + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "INT", + "widget": { + "name": "fps" + }, + "link": null + }, + { + "localized_name": "duration", + "name": "duration", + "type": "FLOAT", + "widget": { + "name": "duration" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [ + 5 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniGenerateVideo" + }, + "widgets_values": [ + "http://127.0.0.1:8000/v1", + "MiniMaxAI/MiniMax-H3", + "Partially restyle the clip and soundtrack", + "", + 160, + 120, + 24, + 4.458 + ] + }, + { + "id": 7, + "type": "SaveVideo", + "pos": [ + 1700, + 300 + ], + "size": [ + 420, + 160 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 5, + "shape": 7 + }, + { + "localized_name": "filename_prefix", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "localized_name": "format", + "name": "format", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [] + } + ], + "properties": { + "Node name for S&R": "SaveVideo" + }, + "widgets_values": [ + "vllm-omni/latent-edit", + "mp4" + ] + } + ], + "links": [ + [ + 1, + 2, + 0, + 4, + 0, + "VIDEO" + ], + [ + 2, + 3, + 0, + 4, + 2, + "MASK" + ], + [ + 3, + 4, + 0, + 6, + 6, + "LATENT_MASK_EDITING" + ], + [ + 4, + 5, + 0, + 6, + 4, + "VIDEO_PARAMS" + ], + [ + 5, + 6, + 0, + 7, + 0, + "VIDEO" + ] + ], + "groups": [], + "config": {}, + "extra": {}, + "version": 0.4 +} diff --git a/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Latent Mask Editing - Temporal Mask.json b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Latent Mask Editing - Temporal Mask.json new file mode 100644 index 000000000..b2b3a6980 --- /dev/null +++ b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Latent Mask Editing - Temporal Mask.json @@ -0,0 +1,560 @@ +{ + "last_node_id": 9, + "last_link_id": 7, + "nodes": [ + { + "id": 2, + "type": "LoadVideo", + "pos": [ + 40, + 100 + ], + "size": [ + 420, + 120 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "file", + "type": "STRING", + "widget": { + "name": "file" + }, + "link": null + } + ], + "outputs": [ + { + "name": "video", + "type": "VIDEO", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "LoadVideo" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 3, + "type": "SolidMask", + "pos": [ + 40, + 300 + ], + "size": [ + 420, + 160 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + } + ], + "outputs": [ + { + "name": "mask", + "type": "MASK", + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "SolidMask" + }, + "widgets_values": [ + 0.0, + 160, + 120 + ] + }, + { + "id": 4, + "type": "SolidMask", + "pos": [ + 40, + 500 + ], + "size": [ + 420, + 160 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + } + ], + "outputs": [ + { + "name": "mask", + "type": "MASK", + "links": [ + 3 + ] + } + ], + "properties": { + "Node name for S&R": "SolidMask" + }, + "widgets_values": [ + 1.0, + 160, + 120 + ] + }, + { + "id": 5, + "type": "BatchMasksNode", + "pos": [ + 250, + 380 + ], + "size": [ + 200, + 100 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "masks.mask0", + "type": "MASK", + "link": 2, + "slot_index": 0 + }, + { + "name": "masks.mask1", + "type": "MASK", + "link": 3, + "slot_index": 1 + } + ], + "outputs": [ + { + "name": "MASK", + "type": "MASK", + "links": [ + 4 + ] + } + ], + "properties": { + "Node name for S&R": "BatchMasksNode" + }, + "widgets_values": [] + }, + { + "id": 6, + "type": "VLLMOmniLatentMaskEditing", + "pos": [ + 450, + 180 + ], + "size": [ + 400, + 120 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "source_video", + "type": "VIDEO", + "link": 1 + }, + { + "name": "source_audio", + "type": "AUDIO", + "link": null + }, + { + "name": "video_mask", + "type": "MASK", + "link": 4 + }, + { + "name": "audio_mask", + "type": "FLOAT", + "widget": { + "name": "audio_mask" + }, + "link": null + } + ], + "outputs": [ + { + "name": "latent_edit", + "type": "LATENT_MASK_EDITING", + "links": [ + 5 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniLatentMaskEditing" + }, + "widgets_values": [ + -1.0 + ] + }, + { + "id": 7, + "type": "VLLMOmniMiniMaxH3Params", + "pos": [ + 40, + 700 + ], + "size": [ + 420, + 160 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "audio_flow_shift", + "type": "FLOAT", + "widget": { + "name": "audio_flow_shift" + }, + "link": null + }, + { + "name": "flow_shift", + "type": "FLOAT", + "widget": { + "name": "flow_shift" + }, + "link": null + } + ], + "outputs": [ + { + "name": "video params", + "type": "VIDEO_PARAMS", + "links": [ + 6 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniMiniMaxH3Params" + }, + "widgets_values": [ + 3.0, + 12.0 + ] + }, + { + "id": 8, + "type": "VLLMOmniGenerateVideo", + "pos": [ + 700, + 180 + ], + "size": [ + 450, + 380 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "frame", + "type": "IMAGE", + "link": null + }, + { + "name": "references", + "type": "VIDEO_REFERENCES", + "link": null + }, + { + "name": "sampling_params", + "type": "SAMPLING_PARAMS", + "link": null + }, + { + "name": "lora", + "type": "LORA", + "link": null + }, + { + "name": "model_params", + "type": "VIDEO_PARAMS", + "link": 6 + }, + { + "name": "fast_h3", + "type": "FAST_H3", + "link": null + }, + { + "name": "latent_edit", + "type": "LATENT_MASK_EDITING", + "link": 5 + }, + { + "name": "url", + "type": "STRING", + "widget": { + "name": "url" + }, + "link": null + }, + { + "name": "model", + "type": "STRING", + "widget": { + "name": "model" + }, + "link": null + }, + { + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": null + }, + { + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": null + }, + { + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "name": "fps", + "type": "INT", + "widget": { + "name": "fps" + }, + "link": null + }, + { + "name": "duration", + "type": "FLOAT", + "widget": { + "name": "duration" + }, + "link": null + } + ], + "outputs": [ + { + "name": "video", + "type": "VIDEO", + "links": [ + 7 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniGenerateVideo" + }, + "widgets_values": [ + "http://127.0.0.1:8000/v1", + "MiniMaxAI/MiniMax-H3", + "temporal mask: regenerate the ending", + "", + 160, + 120, + 24, + 4.458 + ] + }, + { + "id": 9, + "type": "SaveVideo", + "pos": [ + 900, + 180 + ], + "size": [ + 420, + 200 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "video", + "type": "VIDEO", + "link": 7 + }, + { + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "name": "format", + "type": "STRING", + "widget": { + "name": "format" + }, + "link": null + } + ], + "outputs": [ + { + "name": "video", + "type": "VIDEO", + "links": [] + } + ], + "properties": { + "Node name for S&R": "SaveVideo" + }, + "widgets_values": [ + "temporal_mask", + "mp4" + ] + } + ], + "links": [ + [ + 1, + 2, + 0, + 6, + 0, + "VIDEO" + ], + [ + 2, + 3, + 0, + 5, + 0, + "MASK" + ], + [ + 3, + 4, + 0, + 5, + 1, + "MASK" + ], + [ + 4, + 5, + 0, + 6, + 2, + "MASK" + ], + [ + 5, + 6, + 0, + 8, + 6, + "LATENT_MASK_EDITING" + ], + [ + 6, + 7, + 0, + 8, + 4, + "VIDEO_PARAMS" + ], + [ + 7, + 8, + 0, + 9, + 0, + "VIDEO" + ] + ], + "groups": [], + "config": {}, + "extra": {}, + "version": 0.4 +} diff --git a/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Latent Mask Editing.json b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Latent Mask Editing.json new file mode 100644 index 000000000..4192ef4c5 --- /dev/null +++ b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Latent Mask Editing.json @@ -0,0 +1,511 @@ +{ + "id": "latent-mask-editing-7380", + "revision": 0, + "last_node_id": 7, + "last_link_id": 5, + "nodes": [ + { + "id": 1, + "type": "MarkdownNote", + "pos": [ + 40, + 20 + ], + "size": [ + 420, + 210 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Latent-mask editing server prerequisite", + "properties": {}, + "widgets_values": [ + "Requires a vLLM-Omni server that declares latent-mask editing support (MiniMax-H3, EDIT-01). The source clip and video/audio noise masks are uploaded to /v1/videos; 0 preserves, 1 regenerates, fractional blends." + ], + "color": "#432", + "bgcolor": "#000" + }, + { + "id": 2, + "type": "LoadVideo", + "pos": [ + 40, + 280 + ], + "size": [ + 420, + 120 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "file", + "name": "file", + "type": "COMBO", + "widget": { + "name": "file" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "LoadVideo" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 3, + "type": "SolidMask", + "pos": [ + 40, + 460 + ], + "size": [ + 420, + 160 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "mask", + "name": "mask", + "type": "MASK", + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "SolidMask" + }, + "widgets_values": [ + 1.0, + 160, + 120 + ] + }, + { + "id": 4, + "type": "VLLMOmniLatentMaskEditing", + "pos": [ + 560, + 260 + ], + "size": [ + 420, + 300 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "source_video", + "name": "source_video", + "type": "VIDEO", + "link": 1, + "shape": 7 + }, + { + "localized_name": "source_audio", + "name": "source_audio", + "type": "AUDIO", + "link": null + }, + { + "localized_name": "video_mask", + "name": "video_mask", + "type": "MASK", + "link": 2, + "shape": 7 + }, + { + "localized_name": "audio_mask", + "name": "audio_mask", + "type": "FLOAT", + "widget": { + "name": "audio_mask" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "latent_edit", + "name": "latent_edit", + "type": "LATENT_MASK_EDITING", + "links": [ + 3 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniLatentMaskEditing" + }, + "widgets_values": [ + 0.5 + ] + }, + { + "id": 5, + "type": "VLLMOmniMiniMaxH3Params", + "pos": [ + 40, + 680 + ], + "size": [ + 420, + 140 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "audio_flow_shift", + "name": "audio_flow_shift", + "type": "FLOAT", + "widget": { + "name": "audio_flow_shift" + }, + "link": null + }, + { + "localized_name": "flow_shift", + "name": "flow_shift", + "type": "FLOAT", + "widget": { + "name": "flow_shift" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video params", + "name": "video params", + "type": "VIDEO_PARAMS", + "links": [ + 4 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniMiniMaxH3Params" + }, + "widgets_values": [ + 3.0, + 12.0 + ] + }, + { + "id": 6, + "type": "VLLMOmniGenerateVideo", + "pos": [ + 1080, + 200 + ], + "size": [ + 540, + 680 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "frame", + "name": "frame", + "type": "IMAGE", + "link": null, + "shape": 7 + }, + { + "localized_name": "references", + "name": "references", + "type": "VIDEO_REFERENCES", + "link": null, + "shape": 7 + }, + { + "localized_name": "sampling_params", + "name": "sampling_params", + "type": "SAMPLING_PARAMS", + "link": null + }, + { + "localized_name": "lora", + "name": "lora", + "type": "REMOTE_LORA", + "link": null + }, + { + "localized_name": "model_params", + "name": "model_params", + "type": "VIDEO_PARAMS", + "link": 4, + "shape": 7 + }, + { + "localized_name": "fast_h3", + "name": "fast_h3", + "type": "FASTH3_DEPLOYMENT", + "link": null + }, + { + "localized_name": "latent_edit", + "name": "latent_edit", + "type": "LATENT_MASK_EDITING", + "link": 3, + "shape": 7 + }, + { + "localized_name": "url", + "name": "url", + "type": "STRING", + "widget": { + "name": "url" + }, + "link": null + }, + { + "localized_name": "model", + "name": "model", + "type": "STRING", + "widget": { + "name": "model" + }, + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": null + }, + { + "localized_name": "negative_prompt", + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "INT", + "widget": { + "name": "fps" + }, + "link": null + }, + { + "localized_name": "duration", + "name": "duration", + "type": "FLOAT", + "widget": { + "name": "duration" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [ + 5 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniGenerateVideo" + }, + "widgets_values": [ + "http://127.0.0.1:8000/v1", + "MiniMaxAI/MiniMax-H3", + "Partially restyle the clip and soundtrack", + "", + 160, + 120, + 24, + 4.458 + ] + }, + { + "id": 7, + "type": "SaveVideo", + "pos": [ + 1700, + 300 + ], + "size": [ + 420, + 160 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 5, + "shape": 7 + }, + { + "localized_name": "filename_prefix", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "localized_name": "format", + "name": "format", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [] + } + ], + "properties": { + "Node name for S&R": "SaveVideo" + }, + "widgets_values": [ + "vllm-omni/latent-edit", + "mp4" + ] + } + ], + "links": [ + [ + 1, + 2, + 0, + 4, + 0, + "VIDEO" + ], + [ + 2, + 3, + 0, + 4, + 2, + "MASK" + ], + [ + 3, + 4, + 0, + 6, + 6, + "LATENT_MASK_EDITING" + ], + [ + 4, + 5, + 0, + 6, + 4, + "VIDEO_PARAMS" + ], + [ + 5, + 6, + 0, + 7, + 0, + "VIDEO" + ] + ], + "groups": [], + "config": {}, + "extra": {}, + "version": 0.4 +} diff --git a/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni MiniMax-H3 Latent Mask Editing.json b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni MiniMax-H3 Latent Mask Editing.json new file mode 100644 index 000000000..fc4341730 --- /dev/null +++ b/apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni MiniMax-H3 Latent Mask Editing.json @@ -0,0 +1,5241 @@ +{ + "id": "eebf673c-c66a-4f20-9e87-b728a82f6e5f", + "revision": 0, + "last_node_id": 99, + "last_link_id": 162, + "nodes": [ + { + "id": 5, + "type": "VLLMOmniMiniMaxH3Params", + "pos": [ + 1510, + 2160 + ], + "size": [ + 300, + 130 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "audio_flow_shift", + "name": "audio_flow_shift", + "type": "FLOAT", + "widget": { + "name": "audio_flow_shift" + }, + "link": null + }, + { + "localized_name": "flow_shift", + "name": "flow_shift", + "type": "FLOAT", + "widget": { + "name": "flow_shift" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MiniMax-H3 video params", + "name": "MiniMax-H3 video params", + "type": "VIDEO_PARAMS", + "links": [ + 4 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniMiniMaxH3Params" + }, + "widgets_values": [ + 3, + 12 + ], + "widgets_values_named": { + "audio_flow_shift": 3, + "flow_shift": 12 + } + }, + { + "id": 11, + "type": "VLLMOmniDiffusionSampling", + "pos": [ + 580, + 1870 + ], + "size": [ + 410.68458556883115, + 226 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "n", + "name": "n", + "type": "INT", + "widget": { + "name": "n" + }, + "link": null + }, + { + "localized_name": "num_inference_steps", + "name": "num_inference_steps", + "type": "INT", + "widget": { + "name": "num_inference_steps" + }, + "link": null + }, + { + "localized_name": "guidance_scale", + "name": "guidance_scale", + "type": "FLOAT", + "widget": { + "name": "guidance_scale" + }, + "link": null + }, + { + "localized_name": "true_cfg_scale", + "name": "true_cfg_scale", + "type": "FLOAT", + "widget": { + "name": "true_cfg_scale" + }, + "link": null + }, + { + "localized_name": "vae_use_slicing", + "name": "vae_use_slicing", + "type": "BOOLEAN", + "widget": { + "name": "vae_use_slicing" + }, + "link": null + }, + { + "localized_name": "vae_use_tiling", + "name": "vae_use_tiling", + "type": "BOOLEAN", + "widget": { + "name": "vae_use_tiling" + }, + "link": null + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "diffusion sampling params", + "name": "diffusion sampling params", + "type": "SAMPLING_PARAMS", + "links": [ + 10 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniDiffusionSampling" + }, + "widgets_values": [ + 1, + 50, + 1, + 1, + false, + false, + 0, + "fixed" + ], + "widgets_values_named": { + "n": 1, + "num_inference_steps": 50, + "guidance_scale": 1, + "true_cfg_scale": 1, + "vae_use_slicing": false, + "vae_use_tiling": false, + "seed": 0, + "control_after_generate": "fixed" + } + }, + { + "id": 25, + "type": "VLLMOmniMiniMaxH3Params", + "pos": [ + 1510, + 3760 + ], + "size": [ + 300, + 130 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "audio_flow_shift", + "name": "audio_flow_shift", + "type": "FLOAT", + "widget": { + "name": "audio_flow_shift" + }, + "link": null + }, + { + "localized_name": "flow_shift", + "name": "flow_shift", + "type": "FLOAT", + "widget": { + "name": "flow_shift" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MiniMax-H3 video params", + "name": "MiniMax-H3 video params", + "type": "VIDEO_PARAMS", + "links": [ + 30 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniMiniMaxH3Params" + }, + "widgets_values": [ + 3, + 12 + ], + "widgets_values_named": { + "audio_flow_shift": 3, + "flow_shift": 12 + } + }, + { + "id": 29, + "type": "MarkdownNote", + "pos": [ + 1050, + 3470 + ], + "size": [ + 360, + 330 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Inpainting guide", + "properties": { + "Node name for S&R": "MarkdownNote" + }, + "widgets_values": [ + "Replace the masked region with a curled orange cat gently swaying its tail.\n\nThis case has its own rectangular mask. Leave room for the entire cat and tail motion; check the red overlay before generating.\n\nAudio mask 0 preserves source audio latents. To request new sounds, change the audio prompt and mask together. Fractional audio masks do not selectively retain the school chime while adding meows." + ], + "widgets_values_named": { + "text": "Replace the masked region with a curled orange cat gently swaying its tail.\n\nThis case has its own rectangular mask. Leave room for the entire cat and tail motion; check the red overlay before generating.\n\nAudio mask 0 preserves source audio latents. To request new sounds, change the audio prompt and mask together. Fractional audio masks do not selectively retain the school chime while adding meows." + }, + "color": "#333", + "bgcolor": "#222" + }, + { + "id": 30, + "type": "VLLMOmniDiffusionSampling", + "pos": [ + 580, + 3470 + ], + "size": [ + 410.68458556883115, + 226 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "n", + "name": "n", + "type": "INT", + "widget": { + "name": "n" + }, + "link": null + }, + { + "localized_name": "num_inference_steps", + "name": "num_inference_steps", + "type": "INT", + "widget": { + "name": "num_inference_steps" + }, + "link": null + }, + { + "localized_name": "guidance_scale", + "name": "guidance_scale", + "type": "FLOAT", + "widget": { + "name": "guidance_scale" + }, + "link": null + }, + { + "localized_name": "true_cfg_scale", + "name": "true_cfg_scale", + "type": "FLOAT", + "widget": { + "name": "true_cfg_scale" + }, + "link": null + }, + { + "localized_name": "vae_use_slicing", + "name": "vae_use_slicing", + "type": "BOOLEAN", + "widget": { + "name": "vae_use_slicing" + }, + "link": null + }, + { + "localized_name": "vae_use_tiling", + "name": "vae_use_tiling", + "type": "BOOLEAN", + "widget": { + "name": "vae_use_tiling" + }, + "link": null + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "diffusion sampling params", + "name": "diffusion sampling params", + "type": "SAMPLING_PARAMS", + "links": [ + 33 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniDiffusionSampling" + }, + "widgets_values": [ + 1, + 50, + 1, + 1, + false, + false, + 0, + "fixed" + ], + "widgets_values_named": { + "n": 1, + "num_inference_steps": 50, + "guidance_scale": 1, + "true_cfg_scale": 1, + "vae_use_slicing": false, + "vae_use_tiling": false, + "seed": 0, + "control_after_generate": "fixed" + } + }, + { + "id": 34, + "type": "VLLMOmniMiniMaxH3Params", + "pos": [ + 1510, + 5360 + ], + "size": [ + 300, + 130 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "audio_flow_shift", + "name": "audio_flow_shift", + "type": "FLOAT", + "widget": { + "name": "audio_flow_shift" + }, + "link": null + }, + { + "localized_name": "flow_shift", + "name": "flow_shift", + "type": "FLOAT", + "widget": { + "name": "flow_shift" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MiniMax-H3 video params", + "name": "MiniMax-H3 video params", + "type": "VIDEO_PARAMS", + "links": [ + 38 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniMiniMaxH3Params" + }, + "widgets_values": [ + 3, + 12 + ], + "widgets_values_named": { + "audio_flow_shift": 3, + "flow_shift": 12 + } + }, + { + "id": 45, + "type": "MarkdownNote", + "pos": [ + 1050, + 5070 + ], + "size": [ + 360, + 330 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Continuation guide", + "properties": { + "Node name for S&R": "MarkdownNote" + }, + "widgets_values": [ + "Regenerate the ending with an overcast sky, heavy rain, and distant lightning.\n\nPreserve fraction controls how much of the available source prefix is retained. Match Temporal Mask duration to Generate Video duration; these values are not linked.\n\nThe boundary rounds down to complete H3 chunks. White allows regeneration, but does not force existing subjects to disappear.\n\nThe red preview is schematic. Audio mask affects the entire clip, including the preserved video prefix." + ], + "widgets_values_named": { + "text": "Regenerate the ending with an overcast sky, heavy rain, and distant lightning.\n\nPreserve fraction controls how much of the available source prefix is retained. Match Temporal Mask duration to Generate Video duration; these values are not linked.\n\nThe boundary rounds down to complete H3 chunks. White allows regeneration, but does not force existing subjects to disappear.\n\nThe red preview is schematic. Audio mask affects the entire clip, including the preserved video prefix." + }, + "color": "#333", + "bgcolor": "#222" + }, + { + "id": 46, + "type": "VLLMOmniDiffusionSampling", + "pos": [ + 580, + 5070 + ], + "size": [ + 410.68458556883115, + 226 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "n", + "name": "n", + "type": "INT", + "widget": { + "name": "n" + }, + "link": null + }, + { + "localized_name": "num_inference_steps", + "name": "num_inference_steps", + "type": "INT", + "widget": { + "name": "num_inference_steps" + }, + "link": null + }, + { + "localized_name": "guidance_scale", + "name": "guidance_scale", + "type": "FLOAT", + "widget": { + "name": "guidance_scale" + }, + "link": null + }, + { + "localized_name": "true_cfg_scale", + "name": "true_cfg_scale", + "type": "FLOAT", + "widget": { + "name": "true_cfg_scale" + }, + "link": null + }, + { + "localized_name": "vae_use_slicing", + "name": "vae_use_slicing", + "type": "BOOLEAN", + "widget": { + "name": "vae_use_slicing" + }, + "link": null + }, + { + "localized_name": "vae_use_tiling", + "name": "vae_use_tiling", + "type": "BOOLEAN", + "widget": { + "name": "vae_use_tiling" + }, + "link": null + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "diffusion sampling params", + "name": "diffusion sampling params", + "type": "SAMPLING_PARAMS", + "links": [ + 48 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniDiffusionSampling" + }, + "widgets_values": [ + 1, + 50, + 1, + 1, + false, + false, + 0, + "fixed" + ], + "widgets_values_named": { + "n": 1, + "num_inference_steps": 50, + "guidance_scale": 1, + "true_cfg_scale": 1, + "vae_use_slicing": false, + "vae_use_tiling": false, + "seed": 0, + "control_after_generate": "fixed" + } + }, + { + "id": 49, + "type": "VLLMOmniMiniMaxH3Params", + "pos": [ + 1510, + 6960 + ], + "size": [ + 300, + 130 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "audio_flow_shift", + "name": "audio_flow_shift", + "type": "FLOAT", + "widget": { + "name": "audio_flow_shift" + }, + "link": null + }, + { + "localized_name": "flow_shift", + "name": "flow_shift", + "type": "FLOAT", + "widget": { + "name": "flow_shift" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MiniMax-H3 video params", + "name": "MiniMax-H3 video params", + "type": "VIDEO_PARAMS", + "links": [ + 51 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniMiniMaxH3Params" + }, + "widgets_values": [ + 3, + 12 + ], + "widgets_values_named": { + "audio_flow_shift": 3, + "flow_shift": 12 + } + }, + { + "id": 59, + "type": "VLLMOmniMiniMaxH3TemporalMask", + "pos": [ + 580, + 6040 + ], + "size": [ + 420, + 240 + ], + "flags": {}, + "order": 26, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 123 + }, + { + "localized_name": "source_fps", + "name": "source_fps", + "type": "FLOAT", + "widget": { + "name": "source_fps" + }, + "link": 124 + }, + { + "localized_name": "duration", + "name": "duration", + "type": "FLOAT", + "widget": { + "name": "duration" + }, + "link": null + }, + { + "localized_name": "mode", + "name": "mode", + "type": "COMBO", + "widget": { + "name": "mode" + }, + "link": null + }, + { + "localized_name": "preserve_fraction", + "name": "preserve_fraction", + "type": "FLOAT", + "widget": { + "name": "preserve_fraction" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "mask", + "name": "mask", + "type": "MASK", + "links": [ + 60 + ] + }, + { + "localized_name": "preview_fps", + "name": "preview_fps", + "type": "FLOAT", + "links": [ + 146 + ] + }, + { + "localized_name": "preview_images", + "name": "preview_images", + "type": "IMAGE", + "links": [ + 153, + 156 + ] + }, + { + "localized_name": "preview_mask", + "name": "preview_mask", + "type": "MASK", + "links": [ + 155 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniMiniMaxH3TemporalMask" + }, + "widgets_values": [ + 24, + 10, + "extension", + 0.5 + ], + "widgets_values_named": { + "source_fps": 24, + "duration": 10, + "mode": "extension", + "preserve_fraction": 0.5 + } + }, + { + "id": 60, + "type": "MarkdownNote", + "pos": [ + 1050, + 6670 + ], + "size": [ + 360, + 330 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Extension guide", + "properties": { + "Node name for S&R": "MarkdownNote" + }, + "widgets_values": [ + "Extend into a summer starry night. Follow a firework rising behind the school with a slow upward camera tilt, ending at its burst.\n\nSet the same duration in Temporal Mask and Generate Video, longer than the source. These values are not linked. The original source length determines the preserved prefix.\n\nThe boundary rounds down to H3 chunks. Preview holds the final source frame under the red overlay; it is not the generated ending.\n\nAudio mask affects the whole clip, not only the extension." + ], + "widgets_values_named": { + "text": "Extend into a summer starry night. Follow a firework rising behind the school with a slow upward camera tilt, ending at its burst.\n\nSet the same duration in Temporal Mask and Generate Video, longer than the source. These values are not linked. The original source length determines the preserved prefix.\n\nThe boundary rounds down to H3 chunks. Preview holds the final source frame under the red overlay; it is not the generated ending.\n\nAudio mask affects the whole clip, not only the extension." + }, + "color": "#333", + "bgcolor": "#222" + }, + { + "id": 2, + "type": "LoadVideo", + "pos": [ + -268.4516045720269, + 1190 + ], + "size": [ + 420, + 314 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "file", + "name": "file", + "type": "COMBO", + "widget": { + "name": "file" + }, + "link": null + }, + { + "localized_name": "choose file to upload", + "name": "upload", + "type": "IMAGEUPLOAD", + "widget": { + "name": "upload" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 1, + 17, + 28, + 36, + 49 + ] + } + ], + "title": "Shared Source Video | All Cases", + "properties": { + "Node name for S&R": "LoadVideo" + }, + "widgets_values": [ + "", + "image" + ], + "widgets_values_named": { + "file": "", + "upload": "image" + } + }, + { + "id": 62, + "type": "MarkdownNote", + "pos": [ + 1050, + 1870 + ], + "size": [ + 360, + 330 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Object Removal guide", + "properties": { + "Node name for S&R": "MarkdownNote" + }, + "widgets_values": [ + "Reconstruct the surrounding wall inside the mask.\n\nSet this case's Mask Canvas, Mask Rectangle, and Rectangle Position to cover the object, handle, and shadow with some background margin. Preview coverage before generating.\n\nSymmetric expansion by d pixels: x/y minus d; width/height plus 2d. A 32-pixel margin improved this school example.\n\nAudio mask 0 preserves source audio latents; reconstruction is not lossless." + ], + "widgets_values_named": { + "text": "Reconstruct the surrounding wall inside the mask.\n\nSet this case's Mask Canvas, Mask Rectangle, and Rectangle Position to cover the object, handle, and shadow with some background margin. Preview coverage before generating.\n\nSymmetric expansion by d pixels: x/y minus d; width/height plus 2d. A 32-pixel margin improved this school example.\n\nAudio mask 0 preserves source audio latents; reconstruction is not lossless." + }, + "color": "#333", + "bgcolor": "#222" + }, + { + "id": 61, + "type": "VLLMOmniDiffusionSampling", + "pos": [ + 580, + 6670 + ], + "size": [ + 410.68458556883115, + 226 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "n", + "name": "n", + "type": "INT", + "widget": { + "name": "n" + }, + "link": null + }, + { + "localized_name": "num_inference_steps", + "name": "num_inference_steps", + "type": "INT", + "widget": { + "name": "num_inference_steps" + }, + "link": null + }, + { + "localized_name": "guidance_scale", + "name": "guidance_scale", + "type": "FLOAT", + "widget": { + "name": "guidance_scale" + }, + "link": null + }, + { + "localized_name": "true_cfg_scale", + "name": "true_cfg_scale", + "type": "FLOAT", + "widget": { + "name": "true_cfg_scale" + }, + "link": null + }, + { + "localized_name": "vae_use_slicing", + "name": "vae_use_slicing", + "type": "BOOLEAN", + "widget": { + "name": "vae_use_slicing" + }, + "link": null + }, + { + "localized_name": "vae_use_tiling", + "name": "vae_use_tiling", + "type": "BOOLEAN", + "widget": { + "name": "vae_use_tiling" + }, + "link": null + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "diffusion sampling params", + "name": "diffusion sampling params", + "type": "SAMPLING_PARAMS", + "links": [ + 61 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniDiffusionSampling" + }, + "widgets_values": [ + 1, + 50, + 1, + 1, + false, + false, + 0, + "fixed" + ], + "widgets_values_named": { + "n": 1, + "num_inference_steps": 50, + "guidance_scale": 1, + "true_cfg_scale": 1, + "vae_use_slicing": false, + "vae_use_tiling": false, + "seed": 0, + "control_after_generate": "fixed" + } + }, + { + "id": 65, + "type": "CreateVideo", + "pos": [ + 1990, + 1190 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 44, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 130 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "widget": { + "name": "fps" + }, + "link": 131 + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "bit_depth" + }, + "link": null + }, + { + "localized_name": "color_space", + "name": "color_space", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "color_space" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "codec", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "codec" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 77 + ] + } + ], + "title": "Object Removal | Preview Video", + "properties": { + "Node name for S&R": "CreateVideo" + }, + "widgets_values": [ + 30, + "auto", + "sRGB", + "none" + ], + "widgets_values_named": { + "fps": 30, + "bit_depth": "auto", + "color_space": "sRGB", + "codec": "none" + } + }, + { + "id": 69, + "type": "CreateVideo", + "pos": [ + 1990, + 2790 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 46, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 137 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "widget": { + "name": "fps" + }, + "link": 138 + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "bit_depth" + }, + "link": null + }, + { + "localized_name": "color_space", + "name": "color_space", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "color_space" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "codec", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "codec" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 91 + ] + } + ], + "title": "Inpainting | Preview Video", + "properties": { + "Node name for S&R": "CreateVideo" + }, + "widgets_values": [ + 30, + "auto", + "sRGB", + "none" + ], + "widgets_values_named": { + "fps": 30, + "bit_depth": "auto", + "color_space": "sRGB", + "codec": "none" + } + }, + { + "id": 73, + "type": "CreateVideo", + "pos": [ + 1990, + 4390 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 48, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 152 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "widget": { + "name": "fps" + }, + "link": 142 + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "bit_depth" + }, + "link": null + }, + { + "localized_name": "color_space", + "name": "color_space", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "color_space" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "codec", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "codec" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 105 + ] + } + ], + "title": "Continuation | Preview Video", + "properties": { + "Node name for S&R": "CreateVideo" + }, + "widgets_values": [ + 30, + "auto", + "sRGB", + "none" + ], + "widgets_values_named": { + "fps": 30, + "bit_depth": "auto", + "color_space": "sRGB", + "codec": "none" + } + }, + { + "id": 77, + "type": "CreateVideo", + "pos": [ + 1990, + 5990 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 50, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 158 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "widget": { + "name": "fps" + }, + "link": 146 + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "bit_depth" + }, + "link": null + }, + { + "localized_name": "color_space", + "name": "color_space", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "color_space" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "codec", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "codec" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 119 + ] + } + ], + "title": "Extension | Preview Video", + "properties": { + "Node name for S&R": "CreateVideo" + }, + "widgets_values": [ + 30, + "auto", + "sRGB", + "none" + ], + "widgets_values_named": { + "fps": 30, + "bit_depth": "auto", + "color_space": "sRGB", + "codec": "none" + } + }, + { + "id": 79, + "type": "EmptyImage", + "pos": [ + 1090, + 1190 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + }, + { + "localized_name": "color", + "name": "color", + "type": "INT", + "widget": { + "name": "color" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 126 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyImage" + }, + "widgets_values": [ + 32, + 32, + 1, + 16711680 + ], + "widgets_values_named": { + "width": 32, + "height": 32, + "batch_size": 1, + "color": 16711680 + } + }, + { + "id": 80, + "type": "ImageCompositeMasked", + "pos": [ + 1390, + 1190 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 28, + "mode": 0, + "inputs": [ + { + "localized_name": "destination", + "name": "destination", + "type": "IMAGE", + "link": 125 + }, + { + "localized_name": "source", + "name": "source", + "type": "IMAGE", + "link": 126 + }, + { + "localized_name": "mask", + "name": "mask", + "shape": 7, + "type": "MASK", + "link": 127 + }, + { + "localized_name": "x", + "name": "x", + "type": "INT", + "widget": { + "name": "x" + }, + "link": null + }, + { + "localized_name": "y", + "name": "y", + "type": "INT", + "widget": { + "name": "y" + }, + "link": null + }, + { + "localized_name": "resize_source", + "name": "resize_source", + "type": "BOOLEAN", + "widget": { + "name": "resize_source" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 129 + ] + } + ], + "properties": { + "Node name for S&R": "ImageCompositeMasked" + }, + "widgets_values": [ + 0, + 0, + true + ], + "widgets_values_named": { + "x": 0, + "y": 0, + "resize_source": true + } + }, + { + "id": 81, + "type": "ImageBlend", + "pos": [ + 1690, + 1190 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 36, + "mode": 0, + "inputs": [ + { + "localized_name": "image1", + "name": "image1", + "type": "IMAGE", + "link": 128 + }, + { + "localized_name": "image2", + "name": "image2", + "type": "IMAGE", + "link": 129 + }, + { + "localized_name": "blend_factor", + "name": "blend_factor", + "type": "FLOAT", + "widget": { + "name": "blend_factor" + }, + "link": null + }, + { + "localized_name": "blend_mode", + "name": "blend_mode", + "type": "COMBO", + "widget": { + "name": "blend_mode" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 130 + ] + } + ], + "properties": { + "Node name for S&R": "ImageBlend" + }, + "widgets_values": [ + 0.48, + "normal" + ], + "widgets_values_named": { + "blend_factor": 0.48, + "blend_mode": "normal" + } + }, + { + "id": 82, + "type": "EmptyImage", + "pos": [ + 1090, + 2790 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + }, + { + "localized_name": "color", + "name": "color", + "type": "INT", + "widget": { + "name": "color" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 133 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyImage" + }, + "widgets_values": [ + 32, + 32, + 1, + 16711680 + ], + "widgets_values_named": { + "width": 32, + "height": 32, + "batch_size": 1, + "color": 16711680 + } + }, + { + "id": 83, + "type": "ImageCompositeMasked", + "pos": [ + 1390, + 2790 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 30, + "mode": 0, + "inputs": [ + { + "localized_name": "destination", + "name": "destination", + "type": "IMAGE", + "link": 132 + }, + { + "localized_name": "source", + "name": "source", + "type": "IMAGE", + "link": 133 + }, + { + "localized_name": "mask", + "name": "mask", + "shape": 7, + "type": "MASK", + "link": 134 + }, + { + "localized_name": "x", + "name": "x", + "type": "INT", + "widget": { + "name": "x" + }, + "link": null + }, + { + "localized_name": "y", + "name": "y", + "type": "INT", + "widget": { + "name": "y" + }, + "link": null + }, + { + "localized_name": "resize_source", + "name": "resize_source", + "type": "BOOLEAN", + "widget": { + "name": "resize_source" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 136 + ] + } + ], + "properties": { + "Node name for S&R": "ImageCompositeMasked" + }, + "widgets_values": [ + 0, + 0, + true + ], + "widgets_values_named": { + "x": 0, + "y": 0, + "resize_source": true + } + }, + { + "id": 84, + "type": "ImageBlend", + "pos": [ + 1690, + 2790 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 38, + "mode": 0, + "inputs": [ + { + "localized_name": "image1", + "name": "image1", + "type": "IMAGE", + "link": 135 + }, + { + "localized_name": "image2", + "name": "image2", + "type": "IMAGE", + "link": 136 + }, + { + "localized_name": "blend_factor", + "name": "blend_factor", + "type": "FLOAT", + "widget": { + "name": "blend_factor" + }, + "link": null + }, + { + "localized_name": "blend_mode", + "name": "blend_mode", + "type": "COMBO", + "widget": { + "name": "blend_mode" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 137 + ] + } + ], + "properties": { + "Node name for S&R": "ImageBlend" + }, + "widgets_values": [ + 0.48, + "normal" + ], + "widgets_values_named": { + "blend_factor": 0.48, + "blend_mode": "normal" + } + }, + { + "id": 89, + "type": "EmptyImage", + "pos": [ + 1090, + 4390 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + }, + { + "localized_name": "color", + "name": "color", + "type": "INT", + "widget": { + "name": "color" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 148 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyImage" + }, + "widgets_values": [ + 32, + 32, + 1, + 16711680 + ], + "widgets_values_named": { + "width": 32, + "height": 32, + "batch_size": 1, + "color": 16711680 + } + }, + { + "id": 90, + "type": "ImageCompositeMasked", + "pos": [ + 1390, + 4390 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 32, + "mode": 0, + "inputs": [ + { + "localized_name": "destination", + "name": "destination", + "type": "IMAGE", + "link": 147 + }, + { + "localized_name": "source", + "name": "source", + "type": "IMAGE", + "link": 148 + }, + { + "localized_name": "mask", + "name": "mask", + "shape": 7, + "type": "MASK", + "link": 149 + }, + { + "localized_name": "x", + "name": "x", + "type": "INT", + "widget": { + "name": "x" + }, + "link": null + }, + { + "localized_name": "y", + "name": "y", + "type": "INT", + "widget": { + "name": "y" + }, + "link": null + }, + { + "localized_name": "resize_source", + "name": "resize_source", + "type": "BOOLEAN", + "widget": { + "name": "resize_source" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 151 + ] + } + ], + "properties": { + "Node name for S&R": "ImageCompositeMasked" + }, + "widgets_values": [ + 0, + 0, + true + ], + "widgets_values_named": { + "x": 0, + "y": 0, + "resize_source": true + } + }, + { + "id": 91, + "type": "ImageBlend", + "pos": [ + 1690, + 4390 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 40, + "mode": 0, + "inputs": [ + { + "localized_name": "image1", + "name": "image1", + "type": "IMAGE", + "link": 150 + }, + { + "localized_name": "image2", + "name": "image2", + "type": "IMAGE", + "link": 151 + }, + { + "localized_name": "blend_factor", + "name": "blend_factor", + "type": "FLOAT", + "widget": { + "name": "blend_factor" + }, + "link": null + }, + { + "localized_name": "blend_mode", + "name": "blend_mode", + "type": "COMBO", + "widget": { + "name": "blend_mode" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 152 + ] + } + ], + "properties": { + "Node name for S&R": "ImageBlend" + }, + "widgets_values": [ + 0.48, + "normal" + ], + "widgets_values_named": { + "blend_factor": 0.48, + "blend_mode": "normal" + } + }, + { + "id": 92, + "type": "EmptyImage", + "pos": [ + 1090, + 5990 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + }, + { + "localized_name": "color", + "name": "color", + "type": "INT", + "widget": { + "name": "color" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 154 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyImage" + }, + "widgets_values": [ + 32, + 32, + 1, + 16711680 + ], + "widgets_values_named": { + "width": 32, + "height": 32, + "batch_size": 1, + "color": 16711680 + } + }, + { + "id": 93, + "type": "ImageCompositeMasked", + "pos": [ + 1390, + 5990 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 34, + "mode": 0, + "inputs": [ + { + "localized_name": "destination", + "name": "destination", + "type": "IMAGE", + "link": 153 + }, + { + "localized_name": "source", + "name": "source", + "type": "IMAGE", + "link": 154 + }, + { + "localized_name": "mask", + "name": "mask", + "shape": 7, + "type": "MASK", + "link": 155 + }, + { + "localized_name": "x", + "name": "x", + "type": "INT", + "widget": { + "name": "x" + }, + "link": null + }, + { + "localized_name": "y", + "name": "y", + "type": "INT", + "widget": { + "name": "y" + }, + "link": null + }, + { + "localized_name": "resize_source", + "name": "resize_source", + "type": "BOOLEAN", + "widget": { + "name": "resize_source" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 157 + ] + } + ], + "properties": { + "Node name for S&R": "ImageCompositeMasked" + }, + "widgets_values": [ + 0, + 0, + true + ], + "widgets_values_named": { + "x": 0, + "y": 0, + "resize_source": true + } + }, + { + "id": 94, + "type": "ImageBlend", + "pos": [ + 1690, + 5990 + ], + "size": [ + 260, + 150 + ], + "flags": {}, + "order": 42, + "mode": 0, + "inputs": [ + { + "localized_name": "image1", + "name": "image1", + "type": "IMAGE", + "link": 156 + }, + { + "localized_name": "image2", + "name": "image2", + "type": "IMAGE", + "link": 157 + }, + { + "localized_name": "blend_factor", + "name": "blend_factor", + "type": "FLOAT", + "widget": { + "name": "blend_factor" + }, + "link": null + }, + { + "localized_name": "blend_mode", + "name": "blend_mode", + "type": "COMBO", + "widget": { + "name": "blend_mode" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 158 + ] + } + ], + "properties": { + "Node name for S&R": "ImageBlend" + }, + "widgets_values": [ + 0.48, + "normal" + ], + "widgets_values_named": { + "blend_factor": 0.48, + "blend_mode": "normal" + } + }, + { + "id": 1, + "type": "MarkdownNote", + "pos": [ + -268.4516045720269, + 2138.3724768826028 + ], + "size": [ + 420, + 210 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Usage Guide", + "properties": {}, + "widgets_values": [ + "Use a compatible MiniMax-H3 latent-edit server. Select a source video and set the server URL/model in each Generate Video node.\n\nSelect one Save Preview or Save Video output, then use Execute to selected output nodes. Running the whole workflow submits all four cases.\n\nMask: 0 preserves source latents; 1 regenerates. Audio mask applies to the whole clip, not individual sounds. Preview overlays are silent and do not run inference." + ], + "widgets_values_named": { + "text": "Use a compatible MiniMax-H3 latent-edit server. Select a source video and set the server URL/model in each Generate Video node.\n\nSelect one Save Preview or Save Video output, then use Execute to selected output nodes. Running the whole workflow submits all four cases.\n\nMask: 0 preserves source latents; 1 regenerates. Audio mask applies to the whole clip, not individual sounds. Preview overlays are silent and do not run inference." + }, + "color": "#432", + "bgcolor": "#000" + }, + { + "id": 15, + "type": "GetVideoComponents", + "pos": [ + -268.4516045720269, + 2438.3724768826028 + ], + "size": [ + 193.530859375, + 106 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 17 + } + ], + "outputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "links": [ + 120, + 123, + 125, + 128, + 132, + 135 + ] + }, + { + "localized_name": "audio", + "name": "audio", + "type": "AUDIO", + "links": [] + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "links": [ + 121, + 124, + 131, + 138 + ] + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "type": "COMBO", + "links": [] + }, + { + "localized_name": "color_space", + "name": "color_space", + "type": "COMBO", + "links": [] + } + ], + "properties": { + "Node name for S&R": "GetVideoComponents" + } + }, + { + "id": 95, + "type": "SolidMask", + "pos": [ + 580, + 1240 + ], + "size": [ + 300, + 150 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": [ + 159 + ] + } + ], + "title": "Mask Canvas", + "properties": { + "Node name for S&R": "SolidMask" + }, + "widgets_values": [ + 0, + 1344, + 768 + ], + "widgets_values_named": { + "value": 0, + "width": 1344, + "height": 768 + } + }, + { + "id": 97, + "type": "SolidMask", + "pos": [ + 580, + 2840 + ], + "size": [ + 300, + 150 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": [ + 161 + ] + } + ], + "title": "Mask Canvas", + "properties": { + "Node name for S&R": "SolidMask" + }, + "widgets_values": [ + 0, + 1344, + 768 + ], + "widgets_values_named": { + "value": 0, + "width": 1344, + "height": 768 + } + }, + { + "id": 66, + "type": "SaveVideo", + "pos": [ + 2530, + 1240 + ], + "size": [ + 420, + 106 + ], + "flags": {}, + "order": 51, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 77 + }, + { + "localized_name": "filename_prefix", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "localized_name": "format", + "name": "format", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "format.codec", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format.codec" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "codec", + "shape": 7, + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "codec" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [] + } + ], + "title": "Mask Preview (Object Removal)", + "properties": {}, + "widgets_values": [ + "vllm-omni/WF05-object-removal-mask-preview", + "auto", + "auto", + "auto" + ], + "widgets_values_named": { + "filename_prefix": "vllm-omni/WF05-object-removal-mask-preview", + "format": "auto", + "format.codec": "auto", + "codec": "auto" + } + }, + { + "id": 78, + "type": "SaveVideo", + "pos": [ + 2530, + 6040 + ], + "size": [ + 420, + 106 + ], + "flags": {}, + "order": 54, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 119 + }, + { + "localized_name": "filename_prefix", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "localized_name": "format", + "name": "format", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "format.codec", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format.codec" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "codec", + "shape": 7, + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "codec" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [] + } + ], + "title": "Mask Preview (Extension)", + "properties": {}, + "widgets_values": [ + "vllm-omni/WF05-extension-mask-preview", + "auto", + "auto", + "auto" + ], + "widgets_values_named": { + "filename_prefix": "vllm-omni/WF05-extension-mask-preview", + "format": "auto", + "format.codec": "auto", + "codec": "auto" + } + }, + { + "id": 6, + "type": "VLLMOmniGenerateVideo", + "pos": [ + 1940, + 1870 + ], + "size": [ + 440, + 680 + ], + "flags": {}, + "order": 35, + "mode": 0, + "inputs": [ + { + "localized_name": "frame", + "name": "frame", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "references", + "name": "references", + "shape": 7, + "type": "VIDEO_REFERENCES", + "link": null + }, + { + "localized_name": "sampling_params", + "name": "sampling_params", + "shape": 7, + "type": "SAMPLING_PARAMS", + "link": 10 + }, + { + "localized_name": "lora", + "name": "lora", + "shape": 7, + "type": "REMOTE_LORA", + "link": null + }, + { + "localized_name": "model_params", + "name": "model_params", + "shape": 7, + "type": "VIDEO_PARAMS", + "link": 4 + }, + { + "localized_name": "fast_h3", + "name": "fast_h3", + "shape": 7, + "type": "FASTH3_DEPLOYMENT", + "link": null + }, + { + "localized_name": "latent_edit", + "name": "latent_edit", + "shape": 7, + "type": "LATENT_MASK_EDITING", + "link": 3 + }, + { + "localized_name": "url", + "name": "url", + "type": "STRING", + "widget": { + "name": "url" + }, + "link": null + }, + { + "localized_name": "model", + "name": "model", + "type": "STRING", + "widget": { + "name": "model" + }, + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": null + }, + { + "localized_name": "negative_prompt", + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "INT", + "widget": { + "name": "fps" + }, + "link": null + }, + { + "localized_name": "duration", + "name": "duration", + "type": "FLOAT", + "widget": { + "name": "duration" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [ + 5 + ] + } + ], + "title": "Generate Video(Object Removal)", + "properties": { + "Node name for S&R": "VLLMOmniGenerateVideo" + }, + "widgets_values": [ + "http://127.0.0.1:8001/v1", + "MiniMaxAI/MiniMax-H3", + "integrated_multimodal_description: The masked area is a flat, seamless continuation of the surrounding wall, matching its color, texture, and lighting. The wall surface remains consistent throughout the video.\n\noverall_soundscape: \n\nnon_diegetic_music: ", + "", + 1344, + 768, + 24, + 5 + ], + "widgets_values_named": { + "url": "http://127.0.0.1:8001/v1", + "model": "MiniMaxAI/MiniMax-H3", + "prompt": "integrated_multimodal_description: The masked area is a flat, seamless continuation of the surrounding wall, matching its color, texture, and lighting. The wall surface remains consistent throughout the video.\n\noverall_soundscape: \n\nnon_diegetic_music: ", + "negative_prompt": "", + "width": 1344, + "height": 768, + "fps": 24, + "duration": 5 + } + }, + { + "id": 96, + "type": "SolidMask", + "pos": [ + 580, + 1360 + ], + "size": [ + 300, + 150 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": [ + 160 + ] + } + ], + "title": "Mask Rectangle", + "properties": { + "Node name for S&R": "SolidMask" + }, + "widgets_values": [ + 1, + 180, + 168 + ], + "widgets_values_named": { + "value": 1, + "width": 180, + "height": 168 + } + }, + { + "id": 9, + "type": "MaskComposite", + "pos": [ + 580, + 1540 + ], + "size": [ + 300, + 150 + ], + "flags": {}, + "order": 23, + "mode": 0, + "inputs": [ + { + "localized_name": "destination", + "name": "destination", + "type": "MASK", + "link": 159 + }, + { + "localized_name": "source", + "name": "source", + "type": "MASK", + "link": 160 + }, + { + "localized_name": "x", + "name": "x", + "type": "INT", + "widget": { + "name": "x" + }, + "link": null + }, + { + "localized_name": "y", + "name": "y", + "type": "INT", + "widget": { + "name": "y" + }, + "link": null + }, + { + "localized_name": "operation", + "name": "operation", + "type": "COMBO", + "widget": { + "name": "operation" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": [ + 63, + 127 + ] + } + ], + "title": "Spatial Mask | Rectangle Position", + "properties": { + "Node name for S&R": "MaskComposite" + }, + "widgets_values": [ + 1074, + 493, + "add" + ], + "widgets_values_named": { + "x": 1074, + "y": 493, + "operation": "add" + } + }, + { + "id": 98, + "type": "SolidMask", + "pos": [ + 580, + 2960 + ], + "size": [ + 300, + 150 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": [ + 162 + ] + } + ], + "title": "Mask Rectangle", + "properties": { + "Node name for S&R": "SolidMask" + }, + "widgets_values": [ + 1, + 180, + 168 + ], + "widgets_values_named": { + "value": 1, + "width": 180, + "height": 168 + } + }, + { + "id": 99, + "type": "MaskComposite", + "pos": [ + 580, + 3140 + ], + "size": [ + 300, + 150 + ], + "flags": {}, + "order": 24, + "mode": 0, + "inputs": [ + { + "localized_name": "destination", + "name": "destination", + "type": "MASK", + "link": 161 + }, + { + "localized_name": "source", + "name": "source", + "type": "MASK", + "link": 162 + }, + { + "localized_name": "x", + "name": "x", + "type": "INT", + "widget": { + "name": "x" + }, + "link": null + }, + { + "localized_name": "y", + "name": "y", + "type": "INT", + "widget": { + "name": "y" + }, + "link": null + }, + { + "localized_name": "operation", + "name": "operation", + "type": "COMBO", + "widget": { + "name": "operation" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": [ + 35, + 134 + ] + } + ], + "title": "Spatial Mask | Rectangle Position", + "properties": { + "Node name for S&R": "MaskComposite" + }, + "widgets_values": [ + 1074, + 493, + "add" + ], + "widgets_values_named": { + "x": 1074, + "y": 493, + "operation": "add" + } + }, + { + "id": 70, + "type": "SaveVideo", + "pos": [ + 2530, + 2840 + ], + "size": [ + 420, + 106 + ], + "flags": {}, + "order": 52, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 91 + }, + { + "localized_name": "filename_prefix", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "localized_name": "format", + "name": "format", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "format.codec", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format.codec" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "codec", + "shape": 7, + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "codec" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [] + } + ], + "title": "Mask Preview (Inpainting)", + "properties": {}, + "widgets_values": [ + "vllm-omni/WF05-inpainting-mask-preview", + "auto", + "auto", + "auto" + ], + "widgets_values_named": { + "filename_prefix": "vllm-omni/WF05-inpainting-mask-preview", + "format": "auto", + "format.codec": "auto", + "codec": "auto" + } + }, + { + "id": 4, + "type": "VLLMOmniLatentMaskEditing", + "pos": [ + 1510, + 1870 + ], + "size": [ + 300, + 200 + ], + "flags": {}, + "order": 27, + "mode": 0, + "inputs": [ + { + "localized_name": "source_video", + "name": "source_video", + "shape": 7, + "type": "VIDEO", + "link": 1 + }, + { + "localized_name": "source_audio", + "name": "source_audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "video_mask", + "name": "video_mask", + "shape": 7, + "type": "MASK", + "link": 63 + }, + { + "localized_name": "audio_mask", + "name": "audio_mask", + "shape": 7, + "type": "FLOAT", + "widget": { + "name": "audio_mask" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "latent_edit", + "name": "latent_edit", + "type": "LATENT_MASK_EDITING", + "links": [ + 3 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniLatentMaskEditing" + }, + "widgets_values": [ + 0 + ], + "widgets_values_named": { + "audio_mask": 0 + } + }, + { + "id": 7, + "type": "SaveVideo", + "pos": [ + 2530, + 1870 + ], + "size": [ + 420, + 106 + ], + "flags": {}, + "order": 43, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 5 + }, + { + "localized_name": "filename_prefix", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "localized_name": "format", + "name": "format", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "format.codec", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format.codec" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "codec", + "shape": 7, + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "codec" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [] + } + ], + "title": "Save Video (Object Removal)", + "properties": { + "Node name for S&R": "SaveVideo" + }, + "widgets_values": [ + "vllm-omni/WF05-object-removal", + "mp4", + "auto", + "auto" + ], + "widgets_values_named": { + "filename_prefix": "vllm-omni/WF05-object-removal", + "format": "mp4", + "format.codec": "auto", + "codec": "auto" + } + }, + { + "id": 27, + "type": "SaveVideo", + "pos": [ + 2530, + 3470 + ], + "size": [ + 420, + 106 + ], + "flags": {}, + "order": 45, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 31 + }, + { + "localized_name": "filename_prefix", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "localized_name": "format", + "name": "format", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "format.codec", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format.codec" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "codec", + "shape": 7, + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "codec" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [] + } + ], + "title": "Save Video (Inpainting)", + "properties": { + "Node name for S&R": "SaveVideo" + }, + "widgets_values": [ + "vllm-omni/WF05-inpainting", + "mp4", + "auto", + "auto" + ], + "widgets_values_named": { + "filename_prefix": "vllm-omni/WF05-inpainting", + "format": "mp4", + "format.codec": "auto", + "codec": "auto" + } + }, + { + "id": 24, + "type": "VLLMOmniLatentMaskEditing", + "pos": [ + 1510, + 3470 + ], + "size": [ + 300, + 200 + ], + "flags": {}, + "order": 29, + "mode": 0, + "inputs": [ + { + "localized_name": "source_video", + "name": "source_video", + "shape": 7, + "type": "VIDEO", + "link": 28 + }, + { + "localized_name": "source_audio", + "name": "source_audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "video_mask", + "name": "video_mask", + "shape": 7, + "type": "MASK", + "link": 35 + }, + { + "localized_name": "audio_mask", + "name": "audio_mask", + "shape": 7, + "type": "FLOAT", + "widget": { + "name": "audio_mask" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "latent_edit", + "name": "latent_edit", + "type": "LATENT_MASK_EDITING", + "links": [ + 29 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniLatentMaskEditing" + }, + "widgets_values": [ + 0 + ], + "widgets_values_named": { + "audio_mask": 0 + } + }, + { + "id": 26, + "type": "VLLMOmniGenerateVideo", + "pos": [ + 1940, + 3470 + ], + "size": [ + 440, + 680 + ], + "flags": {}, + "order": 37, + "mode": 0, + "inputs": [ + { + "localized_name": "frame", + "name": "frame", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "references", + "name": "references", + "shape": 7, + "type": "VIDEO_REFERENCES", + "link": null + }, + { + "localized_name": "sampling_params", + "name": "sampling_params", + "shape": 7, + "type": "SAMPLING_PARAMS", + "link": 33 + }, + { + "localized_name": "lora", + "name": "lora", + "shape": 7, + "type": "REMOTE_LORA", + "link": null + }, + { + "localized_name": "model_params", + "name": "model_params", + "shape": 7, + "type": "VIDEO_PARAMS", + "link": 30 + }, + { + "localized_name": "fast_h3", + "name": "fast_h3", + "shape": 7, + "type": "FASTH3_DEPLOYMENT", + "link": null + }, + { + "localized_name": "latent_edit", + "name": "latent_edit", + "shape": 7, + "type": "LATENT_MASK_EDITING", + "link": 29 + }, + { + "localized_name": "url", + "name": "url", + "type": "STRING", + "widget": { + "name": "url" + }, + "link": null + }, + { + "localized_name": "model", + "name": "model", + "type": "STRING", + "widget": { + "name": "model" + }, + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": null + }, + { + "localized_name": "negative_prompt", + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "INT", + "widget": { + "name": "fps" + }, + "link": null + }, + { + "localized_name": "duration", + "name": "duration", + "type": "FLOAT", + "widget": { + "name": "duration" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [ + 31 + ] + } + ], + "title": "Generate Video(Inpainting)", + "properties": { + "Node name for S&R": "VLLMOmniGenerateVideo" + }, + "widgets_values": [ + "http://127.0.0.1:8001/v1", + "MiniMaxAI/MiniMax-H3", + "integrated_multimodal_description: A small orange cat rests curled up in the masked area, gently swaying its tail from side to side. Its appearance matches the surrounding anime style, lighting, and shadows.\n\noverall_soundscape:\n\nnon_diegetic_music: ", + "", + 1344, + 768, + 24, + 5 + ], + "widgets_values_named": { + "url": "http://127.0.0.1:8001/v1", + "model": "MiniMaxAI/MiniMax-H3", + "prompt": "integrated_multimodal_description: A small orange cat rests curled up in the masked area, gently swaying its tail from side to side. Its appearance matches the surrounding anime style, lighting, and shadows.\n\noverall_soundscape:\n\nnon_diegetic_music: ", + "negative_prompt": "", + "width": 1344, + "height": 768, + "fps": 24, + "duration": 5 + } + }, + { + "id": 33, + "type": "VLLMOmniLatentMaskEditing", + "pos": [ + 1510, + 5070 + ], + "size": [ + 300, + 200 + ], + "flags": {}, + "order": 31, + "mode": 0, + "inputs": [ + { + "localized_name": "source_video", + "name": "source_video", + "shape": 7, + "type": "VIDEO", + "link": 36 + }, + { + "localized_name": "source_audio", + "name": "source_audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "video_mask", + "name": "video_mask", + "shape": 7, + "type": "MASK", + "link": 47 + }, + { + "localized_name": "audio_mask", + "name": "audio_mask", + "shape": 7, + "type": "FLOAT", + "widget": { + "name": "audio_mask" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "latent_edit", + "name": "latent_edit", + "type": "LATENT_MASK_EDITING", + "links": [ + 37 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniLatentMaskEditing" + }, + "widgets_values": [ + 0.8 + ], + "widgets_values_named": { + "audio_mask": 0.8 + } + }, + { + "id": 35, + "type": "VLLMOmniGenerateVideo", + "pos": [ + 1940, + 5070 + ], + "size": [ + 440, + 680 + ], + "flags": {}, + "order": 39, + "mode": 0, + "inputs": [ + { + "localized_name": "frame", + "name": "frame", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "references", + "name": "references", + "shape": 7, + "type": "VIDEO_REFERENCES", + "link": null + }, + { + "localized_name": "sampling_params", + "name": "sampling_params", + "shape": 7, + "type": "SAMPLING_PARAMS", + "link": 48 + }, + { + "localized_name": "lora", + "name": "lora", + "shape": 7, + "type": "REMOTE_LORA", + "link": null + }, + { + "localized_name": "model_params", + "name": "model_params", + "shape": 7, + "type": "VIDEO_PARAMS", + "link": 38 + }, + { + "localized_name": "fast_h3", + "name": "fast_h3", + "shape": 7, + "type": "FASTH3_DEPLOYMENT", + "link": null + }, + { + "localized_name": "latent_edit", + "name": "latent_edit", + "shape": 7, + "type": "LATENT_MASK_EDITING", + "link": 37 + }, + { + "localized_name": "url", + "name": "url", + "type": "STRING", + "widget": { + "name": "url" + }, + "link": null + }, + { + "localized_name": "model", + "name": "model", + "type": "STRING", + "widget": { + "name": "model" + }, + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": null + }, + { + "localized_name": "negative_prompt", + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "INT", + "widget": { + "name": "fps" + }, + "link": null + }, + { + "localized_name": "duration", + "name": "duration", + "type": "FLOAT", + "widget": { + "name": "duration" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [ + 39 + ] + } + ], + "title": "Generate Video(Continuation)", + "properties": { + "Node name for S&R": "VLLMOmniGenerateVideo" + }, + "widgets_values": [ + "http://127.0.0.1:8001/v1", + "MiniMaxAI/MiniMax-H3", + "integrated_multimodal_description: The sky turns completely overcast, with the sun hidden and no direct sunlight, as torrential rain pours over the school entrance in dense visible streaks and splashes on the wet pavement. Distant lightning flashes in the sky behind the school buildings.\n\noverall_soundscape: Loud, steady rainfall, splashing water, and gusts of wind.\n\nnon_diegetic_music: None.", + "", + 1344, + 768, + 24, + 5 + ], + "widgets_values_named": { + "url": "http://127.0.0.1:8001/v1", + "model": "MiniMaxAI/MiniMax-H3", + "prompt": "integrated_multimodal_description: The sky turns completely overcast, with the sun hidden and no direct sunlight, as torrential rain pours over the school entrance in dense visible streaks and splashes on the wet pavement. Distant lightning flashes in the sky behind the school buildings.\n\noverall_soundscape: Loud, steady rainfall, splashing water, and gusts of wind.\n\nnon_diegetic_music: None.", + "negative_prompt": "", + "width": 1344, + "height": 768, + "fps": 24, + "duration": 5 + } + }, + { + "id": 44, + "type": "VLLMOmniMiniMaxH3TemporalMask", + "pos": [ + 580, + 4440 + ], + "size": [ + 420, + 240 + ], + "flags": {}, + "order": 25, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 120 + }, + { + "localized_name": "source_fps", + "name": "source_fps", + "type": "FLOAT", + "widget": { + "name": "source_fps" + }, + "link": 121 + }, + { + "localized_name": "duration", + "name": "duration", + "type": "FLOAT", + "widget": { + "name": "duration" + }, + "link": null + }, + { + "localized_name": "mode", + "name": "mode", + "type": "COMBO", + "widget": { + "name": "mode" + }, + "link": null + }, + { + "localized_name": "preserve_fraction", + "name": "preserve_fraction", + "type": "FLOAT", + "widget": { + "name": "preserve_fraction" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "mask", + "name": "mask", + "type": "MASK", + "links": [ + 47 + ] + }, + { + "localized_name": "preview_fps", + "name": "preview_fps", + "type": "FLOAT", + "links": [ + 142 + ] + }, + { + "localized_name": "preview_images", + "name": "preview_images", + "type": "IMAGE", + "links": [ + 147, + 150 + ] + }, + { + "localized_name": "preview_mask", + "name": "preview_mask", + "type": "MASK", + "links": [ + 149 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniMiniMaxH3TemporalMask" + }, + "widgets_values": [ + 24, + 5, + "continuation", + 0.2 + ], + "widgets_values_named": { + "source_fps": 24, + "duration": 5, + "mode": "continuation", + "preserve_fraction": 0.2 + } + }, + { + "id": 36, + "type": "SaveVideo", + "pos": [ + 2530, + 5070 + ], + "size": [ + 420, + 106 + ], + "flags": {}, + "order": 47, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 39 + }, + { + "localized_name": "filename_prefix", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "localized_name": "format", + "name": "format", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "format.codec", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format.codec" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "codec", + "shape": 7, + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "codec" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [] + } + ], + "title": "Save Video (Continuation)", + "properties": { + "Node name for S&R": "SaveVideo" + }, + "widgets_values": [ + "vllm-omni/WF05-continuation", + "mp4", + "auto", + "auto" + ], + "widgets_values_named": { + "filename_prefix": "vllm-omni/WF05-continuation", + "format": "mp4", + "format.codec": "auto", + "codec": "auto" + } + }, + { + "id": 74, + "type": "SaveVideo", + "pos": [ + 2530, + 4440 + ], + "size": [ + 420, + 106 + ], + "flags": {}, + "order": 53, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 105 + }, + { + "localized_name": "filename_prefix", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "localized_name": "format", + "name": "format", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "format.codec", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format.codec" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "codec", + "shape": 7, + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "codec" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [] + } + ], + "title": "Mask Preview (Continuation)", + "properties": {}, + "widgets_values": [ + "vllm-omni/WF05-continuation-mask-preview", + "auto", + "auto", + "auto" + ], + "widgets_values_named": { + "filename_prefix": "vllm-omni/WF05-continuation-mask-preview", + "format": "auto", + "format.codec": "auto", + "codec": "auto" + } + }, + { + "id": 50, + "type": "VLLMOmniGenerateVideo", + "pos": [ + 1940, + 6670 + ], + "size": [ + 440, + 680 + ], + "flags": {}, + "order": 41, + "mode": 0, + "inputs": [ + { + "localized_name": "frame", + "name": "frame", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "references", + "name": "references", + "shape": 7, + "type": "VIDEO_REFERENCES", + "link": null + }, + { + "localized_name": "sampling_params", + "name": "sampling_params", + "shape": 7, + "type": "SAMPLING_PARAMS", + "link": 61 + }, + { + "localized_name": "lora", + "name": "lora", + "shape": 7, + "type": "REMOTE_LORA", + "link": null + }, + { + "localized_name": "model_params", + "name": "model_params", + "shape": 7, + "type": "VIDEO_PARAMS", + "link": 51 + }, + { + "localized_name": "fast_h3", + "name": "fast_h3", + "shape": 7, + "type": "FASTH3_DEPLOYMENT", + "link": null + }, + { + "localized_name": "latent_edit", + "name": "latent_edit", + "shape": 7, + "type": "LATENT_MASK_EDITING", + "link": 50 + }, + { + "localized_name": "url", + "name": "url", + "type": "STRING", + "widget": { + "name": "url" + }, + "link": null + }, + { + "localized_name": "model", + "name": "model", + "type": "STRING", + "widget": { + "name": "model" + }, + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": null + }, + { + "localized_name": "negative_prompt", + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "INT", + "widget": { + "name": "fps" + }, + "link": null + }, + { + "localized_name": "duration", + "name": "duration", + "type": "FLOAT", + "widget": { + "name": "duration" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [ + 52 + ] + } + ], + "title": "Generate Video(Extension)", + "properties": { + "Node name for S&R": "VLLMOmniGenerateVideo" + }, + "widgets_values": [ + "http://127.0.0.1:8001/v1", + "MiniMaxAI/MiniMax-H3", + "integrated_multimodal_description: Cut to the same school on a summer night beneath a deep blue sky dotted with stars. A firework rises from behind the school buildings, leaving a glowing trail; the camera slowly tilts upward to follow it into the sky, ending as it bursts into a large bloom of colorful sparks.\n\noverall_soundscape: Soft summer insect sounds, followed by the firework rising with a whistle and bursting with a distant boom and crackling sparks.\n\nnon_diegetic_music: N/A", + "", + 1344, + 768, + 24, + 10 + ], + "widgets_values_named": { + "url": "http://127.0.0.1:8001/v1", + "model": "MiniMaxAI/MiniMax-H3", + "prompt": "integrated_multimodal_description: Cut to the same school on a summer night beneath a deep blue sky dotted with stars. A firework rises from behind the school buildings, leaving a glowing trail; the camera slowly tilts upward to follow it into the sky, ending as it bursts into a large bloom of colorful sparks.\n\noverall_soundscape: Soft summer insect sounds, followed by the firework rising with a whistle and bursting with a distant boom and crackling sparks.\n\nnon_diegetic_music: N/A", + "negative_prompt": "", + "width": 1344, + "height": 768, + "fps": 24, + "duration": 10 + } + }, + { + "id": 48, + "type": "VLLMOmniLatentMaskEditing", + "pos": [ + 1510, + 6670 + ], + "size": [ + 300, + 200 + ], + "flags": {}, + "order": 33, + "mode": 0, + "inputs": [ + { + "localized_name": "source_video", + "name": "source_video", + "shape": 7, + "type": "VIDEO", + "link": 49 + }, + { + "localized_name": "source_audio", + "name": "source_audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "video_mask", + "name": "video_mask", + "shape": 7, + "type": "MASK", + "link": 60 + }, + { + "localized_name": "audio_mask", + "name": "audio_mask", + "shape": 7, + "type": "FLOAT", + "widget": { + "name": "audio_mask" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "latent_edit", + "name": "latent_edit", + "type": "LATENT_MASK_EDITING", + "links": [ + 50 + ] + } + ], + "properties": { + "Node name for S&R": "VLLMOmniLatentMaskEditing" + }, + "widgets_values": [ + 0.9 + ], + "widgets_values_named": { + "audio_mask": 0.9 + } + }, + { + "id": 51, + "type": "SaveVideo", + "pos": [ + 2530, + 6670 + ], + "size": [ + 420, + 338 + ], + "flags": {}, + "order": 49, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 52 + }, + { + "localized_name": "filename_prefix", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "localized_name": "format", + "name": "format", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "format.codec", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "format.codec" + }, + "link": null + }, + { + "localized_name": "codec", + "name": "codec", + "shape": 7, + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "codec" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "links": [] + } + ], + "title": "Save Video (Extension)", + "properties": { + "Node name for S&R": "SaveVideo" + }, + "widgets_values": [ + "vllm-omni/WF05-extension", + "mp4", + "auto", + "auto" + ], + "widgets_values_named": { + "filename_prefix": "vllm-omni/WF05-extension", + "format": "mp4", + "format.codec": "auto", + "codec": "auto" + } + } + ], + "links": [ + [ + 1, + 2, + 0, + 4, + 0, + "VIDEO" + ], + [ + 3, + 4, + 0, + 6, + 6, + "LATENT_MASK_EDITING" + ], + [ + 4, + 5, + 0, + 6, + 4, + "VIDEO_PARAMS" + ], + [ + 5, + 6, + 0, + 7, + 0, + "VIDEO" + ], + [ + 10, + 11, + 0, + 6, + 2, + "SAMPLING_PARAMS" + ], + [ + 17, + 2, + 0, + 15, + 0, + "VIDEO" + ], + [ + 28, + 2, + 0, + 24, + 0, + "VIDEO" + ], + [ + 29, + 24, + 0, + 26, + 6, + "LATENT_MASK_EDITING" + ], + [ + 30, + 25, + 0, + 26, + 4, + "VIDEO_PARAMS" + ], + [ + 31, + 26, + 0, + 27, + 0, + "VIDEO" + ], + [ + 33, + 30, + 0, + 26, + 2, + "SAMPLING_PARAMS" + ], + [ + 35, + 99, + 0, + 24, + 2, + "MASK" + ], + [ + 36, + 2, + 0, + 33, + 0, + "VIDEO" + ], + [ + 37, + 33, + 0, + 35, + 6, + "LATENT_MASK_EDITING" + ], + [ + 38, + 34, + 0, + 35, + 4, + "VIDEO_PARAMS" + ], + [ + 39, + 35, + 0, + 36, + 0, + "VIDEO" + ], + [ + 47, + 44, + 0, + 33, + 2, + "MASK" + ], + [ + 48, + 46, + 0, + 35, + 2, + "SAMPLING_PARAMS" + ], + [ + 49, + 2, + 0, + 48, + 0, + "VIDEO" + ], + [ + 50, + 48, + 0, + 50, + 6, + "LATENT_MASK_EDITING" + ], + [ + 51, + 49, + 0, + 50, + 4, + "VIDEO_PARAMS" + ], + [ + 52, + 50, + 0, + 51, + 0, + "VIDEO" + ], + [ + 60, + 59, + 0, + 48, + 2, + "MASK" + ], + [ + 61, + 61, + 0, + 50, + 2, + "SAMPLING_PARAMS" + ], + [ + 63, + 9, + 0, + 4, + 2, + "MASK" + ], + [ + 77, + 65, + 0, + 66, + 0, + "VIDEO" + ], + [ + 91, + 69, + 0, + 70, + 0, + "VIDEO" + ], + [ + 105, + 73, + 0, + 74, + 0, + "VIDEO" + ], + [ + 119, + 77, + 0, + 78, + 0, + "VIDEO" + ], + [ + 120, + 15, + 0, + 44, + 0, + "IMAGE" + ], + [ + 121, + 15, + 2, + 44, + 1, + "FLOAT" + ], + [ + 123, + 15, + 0, + 59, + 0, + "IMAGE" + ], + [ + 124, + 15, + 2, + 59, + 1, + "FLOAT" + ], + [ + 125, + 15, + 0, + 80, + 0, + "IMAGE" + ], + [ + 126, + 79, + 0, + 80, + 1, + "IMAGE" + ], + [ + 127, + 9, + 0, + 80, + 2, + "MASK" + ], + [ + 128, + 15, + 0, + 81, + 0, + "IMAGE" + ], + [ + 129, + 80, + 0, + 81, + 1, + "IMAGE" + ], + [ + 130, + 81, + 0, + 65, + 0, + "IMAGE" + ], + [ + 131, + 15, + 2, + 65, + 2, + "FLOAT" + ], + [ + 132, + 15, + 0, + 83, + 0, + "IMAGE" + ], + [ + 133, + 82, + 0, + 83, + 1, + "IMAGE" + ], + [ + 134, + 99, + 0, + 83, + 2, + "MASK" + ], + [ + 135, + 15, + 0, + 84, + 0, + "IMAGE" + ], + [ + 136, + 83, + 0, + 84, + 1, + "IMAGE" + ], + [ + 137, + 84, + 0, + 69, + 0, + "IMAGE" + ], + [ + 138, + 15, + 2, + 69, + 2, + "FLOAT" + ], + [ + 142, + 44, + 1, + 73, + 2, + "FLOAT" + ], + [ + 146, + 59, + 1, + 77, + 2, + "FLOAT" + ], + [ + 147, + 44, + 2, + 90, + 0, + "IMAGE" + ], + [ + 148, + 89, + 0, + 90, + 1, + "IMAGE" + ], + [ + 149, + 44, + 3, + 90, + 2, + "MASK" + ], + [ + 150, + 44, + 2, + 91, + 0, + "IMAGE" + ], + [ + 151, + 90, + 0, + 91, + 1, + "IMAGE" + ], + [ + 152, + 91, + 0, + 73, + 0, + "IMAGE" + ], + [ + 153, + 59, + 2, + 93, + 0, + "IMAGE" + ], + [ + 154, + 92, + 0, + 93, + 1, + "IMAGE" + ], + [ + 155, + 59, + 3, + 93, + 2, + "MASK" + ], + [ + 156, + 59, + 2, + 94, + 0, + "IMAGE" + ], + [ + 157, + 93, + 0, + 94, + 1, + "IMAGE" + ], + [ + 158, + 94, + 0, + 77, + 0, + "IMAGE" + ], + [ + 159, + 95, + 0, + 9, + 0, + "MASK" + ], + [ + 160, + 96, + 0, + 9, + 1, + "MASK" + ], + [ + 161, + 97, + 0, + 99, + 0, + "MASK" + ], + [ + 162, + 98, + 0, + 99, + 1, + "MASK" + ] + ], + "groups": [ + { + "id": 1, + "title": "Shared Input", + "bounding": [ + -308.451604572027, + 1080, + 473.7665553206702, + 1508.3724768826028 + ], + "color": "#596477", + "flags": {} + }, + { + "id": 2, + "title": "Output Hub", + "bounding": [ + 2470, + 1080, + 540, + 6400 + ], + "color": "#444", + "flags": {} + }, + { + "id": 10, + "title": "Case 01 | Object Removal", + "bounding": [ + 540, + 1080, + 1880, + 1540 + ], + "color": "#77528d", + "flags": {} + }, + { + "id": 20, + "title": "Case 01 | Object Removal Output", + "bounding": [ + 2490, + 1130, + 500, + 1490 + ], + "color": "#77528d", + "flags": {} + }, + { + "id": 11, + "title": "Case 02 | Inpainting", + "bounding": [ + 540, + 2680, + 1880, + 1540 + ], + "color": "#357e80", + "flags": {} + }, + { + "id": 21, + "title": "Case 02 | Inpainting Output", + "bounding": [ + 2490, + 2730, + 500, + 1490 + ], + "color": "#357e80", + "flags": {} + }, + { + "id": 12, + "title": "Case 03 | Continuation", + "bounding": [ + 540, + 4280, + 1880, + 1540 + ], + "color": "#596477", + "flags": {} + }, + { + "id": 22, + "title": "Case 03 | Continuation Output", + "bounding": [ + 2490, + 4330, + 500, + 1490 + ], + "color": "#596477", + "flags": {} + }, + { + "id": 13, + "title": "Case 04 | Extension", + "bounding": [ + 540, + 5880, + 1880, + 1540 + ], + "color": "#b06634", + "flags": {} + }, + { + "id": 23, + "title": "Case 04 | Extension Output", + "bounding": [ + 2490, + 5930, + 500, + 1490 + ], + "color": "#b06634", + "flags": {} + }, + { + "id": 24, + "title": "Object Removal | Mask Generation", + "bounding": [ + 560, + 1130, + 480, + 620 + ], + "color": "#65794e", + "flags": {} + }, + { + "id": 25, + "title": "Inpainting | Mask Generation", + "bounding": [ + 560, + 2730, + 480, + 620 + ], + "color": "#65794e", + "flags": {} + }, + { + "id": 26, + "title": "Continuation | Mask Generation", + "bounding": [ + 560, + 4330, + 480, + 620 + ], + "color": "#65794e", + "flags": {} + }, + { + "id": 27, + "title": "Extension | Mask Generation", + "bounding": [ + 560, + 5930, + 480, + 620 + ], + "color": "#65794e", + "flags": {} + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.9837545257996714, + "offset": [ + -1807.8247536090078, + -6349.252392678693 + ] + } + }, + "version": 0.4 +} diff --git a/apps/ComfyUI-vLLM-Omni/web/main.js b/apps/ComfyUI-vLLM-Omni/web/main.js index 9aba6260e..f8c633e11 100644 --- a/apps/ComfyUI-vLLM-Omni/web/main.js +++ b/apps/ComfyUI-vLLM-Omni/web/main.js @@ -52,6 +52,7 @@ const FAMILY_BY_OUTPUT = { REMOTE_LORA: "deployment", FASTH3_DEPLOYMENT: "deployment", VIDEO_REFERENCES: "media", + LATENT_MASK_EDITING: "media", }; function familyOf(nodeData) { diff --git a/tests/e2e/features/comfyui/mock_videos_server.py b/tests/e2e/features/comfyui/mock_videos_server.py new file mode 100644 index 000000000..c2ddfbb5e --- /dev/null +++ b/tests/e2e/features/comfyui/mock_videos_server.py @@ -0,0 +1,115 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Minimal mock of vLLM-Omni's ``/v1/videos`` API for e2e tests. + +Records every received multipart field to ``MOCK_STATE_FILE`` (JSON) so a test +can assert exactly what the client serialized, and returns a synthetic MP4. +""" + +import asyncio +import json +import os +import subprocess +import uuid + +from fastapi import FastAPI, Request +from fastapi.responses import Response +from starlette.datastructures import UploadFile + +app = FastAPI() +_JOBS: dict[str, dict] = {} +_STATE_FILE = os.environ.get("MOCK_STATE_FILE") + + +def _record(fields: dict) -> None: + if _STATE_FILE: + with open(_STATE_FILE, "w") as f: + json.dump(fields, f) + + +def _synthetic_mp4() -> bytes: + return subprocess.run( + [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "testsrc2=size=320x240:rate=24:duration=1", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=32000:duration=1", + "-map", + "0:v", + "-map", + "1:a", + "-shortest", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-ar", + "32000", + "-ac", + "2", + "-movflags", + "frag_keyframe+empty_moov", + "-f", + "mp4", + "pipe:1", + ], + check=True, + capture_output=True, + ).stdout + + +@app.post("/v1/videos") +async def create_video(request: Request): + form = await request.form() + fields = {} + for key, value in form.items(): + if isinstance(value, UploadFile): + data = await value.read() + fields[key] = { + "file": True, + "size": len(data), + "filename": value.filename, + "content_type": value.content_type, + } + if value.content_type == "application/json": + fields[key]["json"] = json.loads(data) + else: + fields[key] = str(value) + _record(fields) + + job_id = str(uuid.uuid4()) + _JOBS[job_id] = {"status": "queued"} + return {"id": job_id, "status": "queued"} + + +@app.get("/v1/videos/{job_id}") +async def get_video(job_id: str): + if job_id not in _JOBS: + return {"status": "failed", "error": "unknown job"} + return {"id": job_id, "status": "completed"} + + +@app.get("/v1/videos/{job_id}/content") +async def get_video_content(job_id: str): + if job_id not in _JOBS: + return Response(status_code=404) + mp4 = await asyncio.to_thread(_synthetic_mp4) + return Response(content=mp4, media_type="video/mp4") + + +@app.delete("/v1/videos/{job_id}") +async def delete_video(job_id: str): + _JOBS.pop(job_id, None) + return {"deleted": True} diff --git a/tests/e2e/features/comfyui/test_latent_mask.py b/tests/e2e/features/comfyui/test_latent_mask.py new file mode 100644 index 000000000..e208fd5b4 --- /dev/null +++ b/tests/e2e/features/comfyui/test_latent_mask.py @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Unit tests for latent-mask serialization helpers.""" + +import json + +import pytest +import torch +from comfyui_vllm_omni.utils.latent_mask import scalar_mask_to_json, video_mask_to_grid_json + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_scalar_mask_to_json(): + assert scalar_mask_to_json(0.5) == "0.5" + assert scalar_mask_to_json(1.0) == "1.0" + with pytest.raises(ValueError): + scalar_mask_to_json(1.5) + with pytest.raises(ValueError): + scalar_mask_to_json(-0.1) + + +def test_video_mask_grid_shape(): + mask = torch.zeros(256, 448) + grid = json.loads(video_mask_to_grid_json(mask, width=448, height=256, num_frames=107)) + assert len(grid) == 2 + 5 * ((107 - 5) // 17) + assert len(grid[0]) == 256 // 16 + assert len(grid[0][0]) == 448 // 16 + + +def test_video_mask_grid_shape_non_aligned_frames(): + # 96 snaps up to 107 (17n+5), so Tv == 32, matching the server. + grid = json.loads(video_mask_to_grid_json(torch.zeros(256, 448), width=448, height=256, num_frames=96)) + assert len(grid) == 32 + + +def test_video_mask_grid_shape_short_clip(): + # num_frames <= 5 -> Tv == 2. + grid = json.loads(video_mask_to_grid_json(torch.zeros(64, 64), width=64, height=64, num_frames=1)) + assert len(grid) == 2 + + +def test_video_mask_grid_shape_unaligned_width(): + # width=500 floors to 480 (multiple of 32) -> gw == 30, not 31. + grid = json.loads(video_mask_to_grid_json(torch.zeros(64, 64), width=500, height=256, num_frames=22)) + assert len(grid[0][0]) == 30 + + +def test_video_mask_3d_input(): + grid = json.loads(video_mask_to_grid_json(torch.ones(1, 256, 448), width=448, height=256, num_frames=107)) + assert grid[0][0][0] == 1.0 + + +def test_video_mask_temporal(): + # 3D mask: first half 0 (preserve), second half 1 (regenerate). + mask = torch.zeros(7, 64, 64) + mask[4:] = 1.0 + grid = json.loads(video_mask_to_grid_json(mask, width=64, height=64, num_frames=22)) + assert len(grid) == 7 + assert all(v == 0.0 for row in grid[0] for v in row) + assert all(v == 1.0 for row in grid[-1] for v in row) + + +def test_video_mask_temporal_resize(): + # 2 temporal slices resize to tv == 7. + mask = torch.zeros(2, 64, 64) + mask[1] = 1.0 + grid = json.loads(video_mask_to_grid_json(mask, width=64, height=64, num_frames=22)) + assert len(grid) == 7 + + +def test_video_mask_uniform_preserved(): + grid = json.loads(video_mask_to_grid_json(torch.full((64, 64), 0.5), width=64, height=64, num_frames=107)) + assert all(v == 0.5 for row in grid for cell in row for v in cell) + + +def test_video_mask_values_in_range(): + grid = json.loads(video_mask_to_grid_json(torch.rand(128, 128), width=128, height=128, num_frames=22)) + assert all(0.0 <= v <= 1.0 for row in grid for cell in row for v in cell) diff --git a/tests/e2e/features/comfyui/test_latent_mask_editing.py b/tests/e2e/features/comfyui/test_latent_mask_editing.py new file mode 100644 index 000000000..9162974b6 --- /dev/null +++ b/tests/e2e/features/comfyui/test_latent_mask_editing.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""End-to-end test for latent-mask editing serialization. + +Exercises ``VLLMOmniClient.generate_video`` with a ``latent_edit`` payload +against a mock ``/v1/videos`` server and asserts the multipart fields the +client produced. Runs on CPU: the ComfyUI ``comfy_api`` / ``comfy_extras`` +modules are mocked by this directory's ``conftest.py``. +""" + +import asyncio +import json +import os +import socket +import subprocess +import sys +import time + +import pytest +import torch +from comfy_api.input import VideoInput +from comfyui_vllm_omni.utils.api_client import VLLMOmniClient + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +_TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def mock_server(tmp_path): + port = _free_port() + state_file = tmp_path / "state.json" + env = dict(os.environ, MOCK_STATE_FILE=str(state_file)) + proc = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "mock_videos_server:app", "--host", "127.0.0.1", "--port", str(port)], + cwd=_TESTS_DIR, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + deadline = time.time() + 30 + while time.time() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + break + except OSError: + time.sleep(0.2) + yield f"http://127.0.0.1:{port}/v1", state_file + finally: + proc.terminate() + proc.wait(timeout=10) + + +def test_latent_edit_serialization(mock_server): + base_url, state_file = mock_server + + # A non-trivial video mask requires a source video. The mocked VideoInput + # only needs to provide ``save_to`` for the client's multipart upload. + source_video = VideoInput(b"mock_source_video") + mask = torch.zeros(1, 120, 160) + latent_edit = {"source_video": source_video, "video_mask": mask, "audio_mask": 0.5} + + async def run(): + client = VLLMOmniClient(base_url) + return await client.generate_video( + model="MiniMaxAI/MiniMax-H3", + prompt="restyle the clip", + width=160, + height=120, + num_frames=22, + fps=24, + latent_edit=latent_edit, + ) + + out = asyncio.run(run()) + assert out is not None + + with open(state_file) as f: + fields = json.load(f) + + assert fields["source_video"]["file"] is True + assert fields["source_video"]["content_type"] == "video/mp4" + for name in ("video_noise_mask", "audio_noise_mask"): + assert fields[name]["file"] is True + assert fields[name]["content_type"] == "application/json" + assert fields["audio_noise_mask"]["json"] == 0.5 + + video_mask = fields["video_noise_mask"]["json"] + assert len(video_mask) == 7 # num_frames=22 aligns to 22 (17n+5) -> Tv = 7 + assert len(video_mask[0]) == 6 # height 120 floors to 96 (multiple of 32) -> gh 6 + assert len(video_mask[0][0]) == 10 # width 160 -> gw 10 From 856d19f42cfccb1ae97aea1131b89662368baadb Mon Sep 17 00:00:00 2001 From: Zhou Taichang Date: Mon, 21 Sep 2026 21:12:00 +0800 Subject: [PATCH 09/21] [Benchmark] Add LingBot-World realtime streaming benchmark (#7645) Signed-off-by: tzhouam Co-authored-by: Claude Opus 5 (1M context) --- benchmarks/README.md | 11 + benchmarks/lingbot_world/README.md | 224 +++++ benchmarks/lingbot_world/__init__.py | 2 + .../benchmark_lingbot_world_realtime.py | 642 ++++++++++++++ .../configs/single_gpu_eager.yaml | 45 + .../lingbot_world/configs/usp4_compiled.yaml | 60 ++ benchmarks/lingbot_world/workload.py | 631 ++++++++++++++ .../benchmarks/test_lingbot_world_realtime.py | 798 ++++++++++++++++++ 8 files changed, 2413 insertions(+) create mode 100644 benchmarks/lingbot_world/README.md create mode 100644 benchmarks/lingbot_world/__init__.py create mode 100644 benchmarks/lingbot_world/benchmark_lingbot_world_realtime.py create mode 100644 benchmarks/lingbot_world/configs/single_gpu_eager.yaml create mode 100644 benchmarks/lingbot_world/configs/usp4_compiled.yaml create mode 100644 benchmarks/lingbot_world/workload.py create mode 100644 tests/benchmarks/test_lingbot_world_realtime.py diff --git a/benchmarks/README.md b/benchmarks/README.md index 182bba02d..1a23b1d48 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -41,6 +41,17 @@ LingBot-Video MoE transformer. - **Dense pipeline**: decoded-video MAE, MSE, PSNR, latency, and optional steady-state timings - **MoE transformer**: bitwise router, sparse-block, shared-expert, and full-transformer parity +### [LingBot-World](lingbot_world/README.md) — Realtime Streaming Cadence + +Client-side benchmark for a `WS /v1/realtime/video` world-model session, where one +request is a whole rollout and one AR block streams out as one video chunk. It +measures a cadence rather than a throughput: there is exactly one session, and what +matters is whether the next chunk arrives before the viewer finishes the last one. + +- **Layout**: `lingbot_world/benchmark_lingbot_world_realtime.py` (WS client), `lingbot_world/workload.py` (rollout and metric math), `lingbot_world/configs/` (single-GPU eager and Ulysses-4 compiled deploy configs) +- **Workload**: built-in image-conditioned rollout with a generated camera script, or a JSON spec with per-chunk actions and mid-rollout prompt updates +- **Key metrics**: TTFC (time to first chunk), steady-state chunk inter-arrival percentiles, VIDEO_RTF (wall seconds per video second; lower is better), chunk-deadline attainment, and simulated playback underruns + ### [Distributed](distributed/omni_connectors/README.md) — RDMA Connector Testing RDMA environment setup and transfer tests for `MooncakeTransferEngineConnector`, including pytest-based single-node checks and manual cross-node benchmarks. diff --git a/benchmarks/lingbot_world/README.md b/benchmarks/lingbot_world/README.md new file mode 100644 index 000000000..1dce2d7ce --- /dev/null +++ b/benchmarks/lingbot_world/README.md @@ -0,0 +1,224 @@ +# LingBot-World Benchmarks — Realtime Streaming Cadence + +`benchmark_lingbot_world_realtime.py` measures LingBot-World 2.0 as it is +actually deployed: one `WS /v1/realtime/video` session that streams one video +chunk per AR block, for as long as the world keeps running. + +## Why this is not the diffusion serving benchmark + +[`benchmarks/diffusion`](../diffusion/README.md) measures "one request, one +finished video" and reports request throughput. LingBot-World has neither shape: + +- **One request is the whole rollout.** `prepare_encode` runs once, then every AR + block is four DMD steps plus one clean-KV commit forward, and each block + streams out as a chunk. Throughput in requests per second is meaningless. +- **There is exactly one session.** The stepwise deploy config pins + `max_num_seqs: 1`, and the AR-Diffusion consumer rejects a stage with more than + one replica, because session-affine routing does not exist yet. There is no + concurrency to sweep. +- **What matters is cadence.** A deployment is healthy when chunk *N + 1* arrives + before a viewer finishes watching chunk *N*. That is a latency distribution + against a wall clock, not a throughput number. + +## Quick start + +Serve the model, then point the benchmark at it. The benchmark never starts a +server. + +```bash +# Terminal 1 — one GPU, eager (portable smoke configuration) +vllm serve robbyant/lingbot-world-v2-14b-causal-fast-diffusers \ + --omni \ + --deploy-config benchmarks/lingbot_world/configs/single_gpu_eager.yaml \ + --port 8000 + +# Terminal 2 +pip install websockets +python benchmarks/lingbot_world/benchmark_lingbot_world_realtime.py \ + --host 127.0.0.1 --port 8000 \ + --num-chunks 16 \ + --output-json /tmp/lingbot_world_realtime.json +``` + +With no `--image`, the first frame comes from the shared vLLM image asset, so the +default workload needs no setup and this repository carries no benchmark binary. +Pass `--image /path/to/frame.png` to condition the rollout on your own frame. + +### Realtime topology + +The single-GPU config is a smoke configuration, not a realtime one: one card runs +all five forwards of every block serially and nothing is compiled. Use the +four-GPU config for numbers that mean anything about realtime behavior. + +```bash +vllm serve robbyant/lingbot-world-v2-14b-causal-fast-diffusers \ + --omni \ + --deploy-config benchmarks/lingbot_world/configs/usp4_compiled.yaml \ + --port 8000 + +python benchmarks/lingbot_world/benchmark_lingbot_world_realtime.py \ + --port 8000 --num-chunks 40 --warmup-sessions 1 --sessions 3 \ + --target-fps 12 --output-json /tmp/lingbot_world_repeated.json +``` + +`usp4_compiled.yaml` sets pure Ulysses sequence parallelism over four ranks and +`enforce_eager: false`. The pipeline validates the parallel shape itself: +`sequence_parallel_size` must equal `ulysses_degree`, `ring_degree` and +`allgather_degree` must be 1, `ulysses_mode` must be `strict`, and pipeline, +CFG, and VAE parallel sizes above 1 are rejected. This benchmark config sets +`tensor_parallel_size: 1`; the pipeline validator does not reject larger TP sizes. + +Compiled mode can pay compilation and capture costs on the first rollout. +Use `--warmup-sessions 1` before measured repetitions. Excluding the first six +chunks addresses the attention-window ramp; it does not guarantee that +compilation and capture have finished. + +## Metrics + +| Metric | Meaning | +| --- | --- | +| `ttfc_ms` | **Time to first chunk**: `session.start` sent until the first chunk's bytes arrive. Pays for prompt encode, first-frame VAE encode, and any compile or capture. | +| `interval_all` | Inter-arrival latency across every chunk: mean, median, std, min, p90, p95, p99, max. | +| `interval_steady` | The same summary over steady-state chunks only. | +| `video_rtf` | Wall seconds spent per second of video, matching vLLM-Omni's own RTF (`generation_s / video_duration`). **Lower is better; below 1.0 is real time.** | +| `video_rtfx` | The reciprocal, under the name this repo uses for it: video seconds per wall second, where higher is better. | +| `steady_video_rtf` | `video_rtf` restricted to steady-state chunks. | +| `chunk_deadline_ms` | Wall-clock budget one steady chunk must meet to hold real time: 12 frames ÷ `--target-fps`. Override directly with `--chunk-slo-ms`. | +| `slo_attainment` | Fraction of steady chunks that met the deadline. | +| `playback` | Underrun count, total stall, and stall ratio from a simulated viewer. | + +### Which frame rate counts as real time + +**The checkpoint declares no frame rate.** There is none in `model_index.json`, the +scheduler config, or the transformer config, and the pipeline never reads one. A +chunk is twelve pixel frames; how long those frames *last* is a property of how the +output is played, not of the model. + +That choice moves the verdict without moving anything measured: + +| basis | video per chunk | deadline | RTF at a measured 1357 ms/chunk | +| --- | --- | --- | --- | +| 16 fps | 0.750 s | 750 ms | 1.809 | +| 12 fps | 1.000 s | 1000 ms | 1.357 | + +So `--fps` (sent to the server, which uses it to label the muxed MP4) and +`--target-fps` (the real-time basis for RTF, the deadline, and the playback +simulation) are separate flags. `--target-fps` defaults to `--fps`; set it when the +two differ, and the report prints both. State the basis whenever you quote a number +— an RTF without its fps is not a measurement. (RTF is a cost here, so a bigger number is a +worse result; a steady chunk's RTF is simply its interval divided by its video duration.) +An RTF above 1 means the measured deployment is slower than real time at the +stated target frame rate. The benchmark measures this failure as well as success. + +### Why warmup chunks are not optional + +The DiT attends over a sliding window of `sliding_window_num_frames = 18` latent +frames with `sink_size = 9`, so the key count a block attends over keeps growing +until eighteen latent frames of history exist — six chunks at three frames per +block. **Chunk cost is not stationary before chunk six.** A mean over a short +rollout is therefore lower than anything a running session will sustain, which is +why `--warmup-chunks` defaults to 6 and steady-state metrics are reported +separately. Keep `--num-chunks` comfortably above the warmup, or the benchmark +tells you no chunk reached steady state. The terminal chunk skips next-chunk +preparation, so it is excluded from steady metrics too; it remains in overall +RTF, all-chunk intervals, and playback simulation. + +The default 16-chunk rollout provides only nine steady intervals. Percentiles +are interpolated sample summaries; the report warns when fewer than 100 +intervals contribute, and even 100 samples do not guarantee a stable p99. +Use longer rollouts for tail analysis and at least `--sessions 3` after +`--warmup-sessions 1` for reported comparisons. Session warmup pays compilation +cost; chunk warmup separately excludes the attention-window ramp. The command +above yields 33 steady intervals per session (indices 6 through 38), or 99 across +three sessions. Report each session's mean and the spread across sessions; this +sample still does not establish a reliable p99. + +### Why playback is simulated + +A p99 interval alone cannot say whether a viewer saw a stall: the same p99 is +invisible behind a three-chunk buffer and a visible freeze behind none. The +benchmark replays chunk arrivals against a wall clock — playback starts once +`--playback-buffer-chunks` chunks have landed and then consumes video at `--target-fps` +— and reports every moment the player ran dry. + +### What the client-side number is worth + +On a four-GPU Ulysses run the client's mean inter-arrival over all fifteen +post-first-chunk intervals was **1326.743 ms**, against the server's own +`StageRequestStats.inter_output_latency_ms` of **1327.479 ms** for the same +request — a difference of **0.74 ms**. This cross-check shows agreement in +average output cadence for that run, including warmup and the terminal chunk. +It does not measure encoding or transport latency: a fixed delivery delay can +cancel out of inter-arrival differences. Attributing that latency requires +per-chunk timestamps at both boundaries or server-side profiling. + +Compare matching interval populations when investigating discrepancies; both +measurement bugs and delivery buffering can change the observed cadence. + +### What this benchmark cannot see + +Chunk metadata carries no server-side timestamps, so the benchmark cannot split +DiT time from VAE decode time; use `--enable-diffusion-pipeline-profiler` on the +server for that. It also reports no GPU memory, which is a server-side quantity. + +## Custom rollouts + +`--workload rollout.json` replaces the built-in workload, including mid-rollout +prompt changes, which the CLI flags cannot express: + +```json +{ + "prompt": "The camera moves slowly forward through the scene.", + "image": "/path/to/first_frame.png", + "num_chunks": 24, + "camera_pattern": "orbit", + "fps": 16, + "seed": 42, + "prompt_updates": [ + {"after_chunk": 8, "prompt": "Rain begins to fall", "transition_chunks": 2} + ] +} +``` + +Supply `camera_action_script` directly instead of `num_chunks` to control every +frame's action. It must hold exactly one three-entry action list per chunk; one +AR block is three latent frames, and the server rejects any other shape. +Explicit `--image`, `--prompt`, `--negative-prompt`, `--width`, `--height`, +`--fps`, `--seed`, and `--flow-shift` flags override the matching workload-file +values. `--num-chunks` and `--camera-pattern` apply only to the built-in +workload; when using `--workload`, set those fields in the file instead. +An explicit `camera_action_script` takes precedence over the file's +`num_chunks` and `camera_pattern`. Each prompt update must use a distinct +`after_chunk` boundary so its interaction event ID is unique. + +Camera actions change what the world does, not what it costs, so `--camera-pattern` +(`forward`, `orbit`, `hold`) keeps a rollout representative rather than sweeping a +cost dimension. + +## Options worth knowing + +| Flag | Purpose | +| --- | --- | +| `--target-fps` | Playback rate the real-time verdict is measured against, independent of the mux label. | +| `--sessions N` | N sequential measured rollouts; the report adds spread across them. | +| `--warmup-sessions N` | Unmeasured rollouts first, to pay compile and capture cost. | +| `--print-chunks` | One line per chunk as it arrives. | +| `--save-video PATH` | Remux the last measured session for eyeballing (needs vLLM-Omni). | +| `--ping-interval` | `session.ping` cadence keeping the server's stall clock fresh during a slow first chunk. `0` disables. | +| `--first-chunk-timeout` | Raise it when the server is still compiling or capturing graphs. | + +## Output + +`--output-json` writes the run's configuration, every chunk's arrival time and +size, per-session metrics, and the aggregate, along with the git revision and +hostname so a number can be traced back to what produced it. + +## Tests + +`tests/benchmarks/test_lingbot_world_realtime.py` covers the chunk and frame +arithmetic against the server's own formula, the metric math, and the protocol +handling against a scripted WebSocket server. It needs no GPU: + +```bash +pytest tests/benchmarks/test_lingbot_world_realtime.py +``` diff --git a/benchmarks/lingbot_world/__init__.py b/benchmarks/lingbot_world/__init__.py new file mode 100644 index 000000000..4244ce4b4 --- /dev/null +++ b/benchmarks/lingbot_world/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project diff --git a/benchmarks/lingbot_world/benchmark_lingbot_world_realtime.py b/benchmarks/lingbot_world/benchmark_lingbot_world_realtime.py new file mode 100644 index 000000000..186bc1393 --- /dev/null +++ b/benchmarks/lingbot_world/benchmark_lingbot_world_realtime.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Realtime streaming benchmark for LingBot-World 2.0 over ``WS /v1/realtime/video``. + +Every other diffusion benchmark in this repository measures "one request, one +finished video". LingBot-World is not that: one request is a whole rollout, and +the thing a deployment lives or dies by is whether chunk *N + 1* keeps arriving +before a viewer has finished watching chunk *N*. This benchmark therefore +measures a **cadence**, not a throughput: + +* ``TTFC`` -- session.start to the first chunk's bytes, which pays for prompt + encode, the first-frame VAE encode, and any compile or capture the deploy + config asks for; +* **inter-arrival latency** over all chunks and, separately, over the steady + state after the attention window saturates; +* ``VIDEO_RTF`` -- wall seconds spent per second of video generated, matching how + vLLM-Omni computes RTF everywhere else (``generation_s / video_duration``). + **Lower is better and below 1.0 is real time**; ``VIDEO_RTFX`` is reported + alongside it as the reciprocal, under the repo's own name for that; +* **playback continuity** -- a simulated viewer with a start buffer, because a + p99 that is harmless behind a three-chunk buffer is a visible stall behind + none. + +The server is not started here. Bring one up first (see the README), then point +this script at it. Requires ``websockets``; ``--save-video`` additionally needs +a vLLM-Omni installation for the remux helper. + +Example: + + python benchmarks/lingbot_world/benchmark_lingbot_world_realtime.py \\ + --host 127.0.0.1 --port 8000 \\ + --num-chunks 16 \\ + --output-json /tmp/lingbot_world_realtime.json +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import mimetypes +import os +import platform +import subprocess +import sys +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from benchmarks.lingbot_world.workload import ( # noqa: E402 + CAMERA_PATTERNS, + DEFAULT_FLOW_SHIFT, + DEFAULT_FPS, + DEFAULT_HEIGHT, + DEFAULT_MODEL, + DEFAULT_PROMPT, + DEFAULT_SEED, + DEFAULT_WARMUP_CHUNKS, + DEFAULT_WIDTH, + ChunkRecord, + Workload, + aggregate_metrics, + compute_metrics, + default_workload, + load_workload, + steady_chunk_deadline_ms, +) + +# The shared vLLM asset keeps the default workload runnable with no setup and +# adds no binary to this repository. Any real photograph conditions a rollout. +_DEFAULT_IMAGE_ASSET = "2560px-Gfp-wisconsin-madison-the-nature-boardwalk" +_WS_PATH = "/v1/realtime/video" + + +class BenchmarkError(RuntimeError): + """A session failed in a way that invalidates its numbers.""" + + +# --------------------------------------------------------------------------- # +# Input resolution +# --------------------------------------------------------------------------- # + + +def resolve_image_reference(value: str | None) -> str: + """Return an ``http(s)`` or ``data:`` URL for the session's first frame.""" + if value is None: + from vllm.assets.image import ImageAsset + + value = str(ImageAsset(_DEFAULT_IMAGE_ASSET).get_path("jpg")) + if value.startswith(("http://", "https://", "data:")): + return value + path = Path(value).expanduser() + if not path.is_file(): + raise BenchmarkError(f"--image is neither a URL nor a readable file: {value}") + media_type = mimetypes.guess_type(path.name)[0] or "image/png" + return f"data:{media_type};base64,{base64.b64encode(path.read_bytes()).decode()}" + + +def build_workload(args: argparse.Namespace) -> Workload: + if args.workload is not None: + # The file names its own first frame, so resolving happens inside + # load_workload -- never eagerly, which would fetch the default asset a + # --workload run has no use for. + return load_workload( + args.workload, + image_resolver=lambda value: resolve_image_reference(value if args.image is None else args.image), + overrides={ + "width": args.width_override, + "height": args.height_override, + "fps": args.fps_override, + "seed": args.seed_override, + "prompt": args.prompt_override, + "flow_shift": args.flow_shift, + "negative_prompt": args.negative_prompt, + }, + ) + return default_workload( + image_reference=resolve_image_reference(args.image), + num_chunks=args.num_chunks, + camera_pattern=args.camera_pattern, + prompt=args.prompt_override if args.prompt_override is not None else DEFAULT_PROMPT, + width=args.width_override if args.width_override is not None else DEFAULT_WIDTH, + height=args.height_override if args.height_override is not None else DEFAULT_HEIGHT, + fps=args.fps_override if args.fps_override is not None else DEFAULT_FPS, + seed=args.seed_override if args.seed_override is not None else DEFAULT_SEED, + flow_shift=args.flow_shift if args.flow_shift is not None else DEFAULT_FLOW_SHIFT, + negative_prompt=args.negative_prompt, + ) + + +# --------------------------------------------------------------------------- # +# One streaming session +# --------------------------------------------------------------------------- # + + +@dataclass +class SessionResult: + request_id: str | None + records: list[ChunkRecord] + media: bytes + ttfc_s: float | None + video_start_s: float | None + wall_s: float + done_event: dict[str, Any] + + +async def _keepalive(websocket: Any, send_lock: asyncio.Lock, interval: float) -> None: + """Refresh the server's stall clock while a slow first chunk is generating. + + A compiled deploy config records CUDA graphs on the first block, which can + outlast the server's ~60 s stall timeout. ``session.ping`` is the documented + way to say the client is still there. + """ + while True: + await asyncio.sleep(interval) + async with send_lock: + await websocket.send(json.dumps({"type": "session.ping"})) + + +async def run_session( + url: str, + model: str, + workload: Workload, + *, + first_chunk_timeout: float, + chunk_timeout: float, + ping_interval: float, + collect_media: bool, + print_chunks: bool, +) -> SessionResult: + """Drive one rollout and timestamp every chunk as its bytes land.""" + try: + from websockets.asyncio.client import connect + except ImportError: # pragma: no cover - exercised only without websockets + try: + from websockets import connect # type: ignore[attr-defined] + except ImportError as exc: + raise BenchmarkError("This benchmark needs the websockets package: pip install websockets") from exc + + payload = workload.session_start_payload(model) + records: list[ChunkRecord] = [] + media = bytearray() + pending: dict[str, Any] | None = None + request_id: str | None = None + ttfc_s: float | None = None + video_start_s: float | None = None + last_arrival: float | None = None + done_event: dict[str, Any] = {} + send_lock = asyncio.Lock() + keepalive: asyncio.Task[None] | None = None + # Fire remaining prompt updates in chunk order as their boundary passes. + updates = sorted(workload.prompt_updates, key=lambda update: update.after_chunk) + update_index = 0 + + async with connect(url, max_size=None, ping_interval=None) as websocket: + async with send_lock: + await websocket.send(json.dumps(payload, ensure_ascii=False)) + started = time.perf_counter() + if ping_interval > 0: + keepalive = asyncio.create_task(_keepalive(websocket, send_lock, ping_interval)) + + try: + while True: + timeout = first_chunk_timeout if not records else chunk_timeout + try: + deadline = started + (last_arrival if last_arrival is not None else 0.0) + timeout + remaining = deadline - time.perf_counter() + if remaining <= 0: + raise asyncio.TimeoutError + message = await asyncio.wait_for(websocket.recv(), timeout=remaining) + except asyncio.TimeoutError as exc: + raise BenchmarkError( + f"No media chunk for {timeout:g} s after {len(records)} chunk(s); " + "the server may still be loading, compiling, or capturing graphs." + ) from exc + now = time.perf_counter() + + if isinstance(message, (bytes, bytearray)): + if pending is None: + raise BenchmarkError("Received a binary frame with no preceding video.chunk_metadata event.") + if collect_media: + media.extend(message) + if pending.get("kind") == "media": + index = int(pending["generation_chunk_index"]) + arrival = now - started + if ttfc_s is None: + ttfc_s = arrival + records.append( + ChunkRecord( + index=index, + arrival_s=arrival, + inter_arrival_s=arrival - (last_arrival if last_arrival is not None else 0.0), + byte_length=len(message), + num_frames=int(pending["num_frames"]), + ) + ) + last_arrival = arrival + if print_chunks: + print( + f" chunk {index:3d} t={arrival:8.3f}s " + f"interval={records[-1].inter_arrival_s * 1000:8.1f}ms " + f"frames={records[-1].num_frames:3d} bytes={len(message)}", + flush=True, + ) + while update_index < len(updates) and updates[update_index].after_chunk <= index: + async with send_lock: + await websocket.send(json.dumps(updates[update_index].to_payload(), ensure_ascii=False)) + update_index += 1 + pending = None + continue + + event = json.loads(message) + event_type = event.get("type") + if event_type == "video.chunk_metadata": + pending = event + elif event_type == "video.start": + request_id = event.get("request_id") + video_start_s = now - started + elif event_type == "error": + # Say what was asked for alongside what the server refused: + # some builds cap a rollout's num_frames, and the operator + # needs to know which --num-chunks would fit. + raise BenchmarkError( + f"Server rejected the session after {len(records)} chunk(s): " + f"{event.get('message', event)} " + f"(requested {workload.num_chunks} chunks = num_frames {workload.num_frames})" + ) + elif event_type == "session.done": + done_event = event + break + finally: + if keepalive is not None: + keepalive.cancel() + try: + await keepalive + except asyncio.CancelledError: + pass + + wall_s = time.perf_counter() - started + + if not records: + raise BenchmarkError("The session finished without delivering a single media chunk.") + if done_event.get("stopped"): + raise BenchmarkError(f"The session was stopped before it finished: {done_event}") + observed = [record.index for record in records] + if observed != list(range(len(records))): + raise BenchmarkError(f"Chunk indices are not contiguous from zero: {observed}") + if len(records) != workload.num_chunks: + raise BenchmarkError( + f"Expected {workload.num_chunks} chunks for num_frames={workload.num_frames}, received {len(records)}." + ) + + return SessionResult( + request_id=request_id, + records=records, + media=bytes(media), + ttfc_s=ttfc_s, + video_start_s=video_start_s, + wall_s=wall_s, + done_event=done_event, + ) + + +# --------------------------------------------------------------------------- # +# Reporting +# --------------------------------------------------------------------------- # + + +def _git_revision() -> str | None: + try: + return subprocess.check_output( + ["git", "-C", str(REPO_ROOT), "rev-parse", "HEAD"], + text=True, + stderr=subprocess.DEVNULL, + ).strip() + except (OSError, subprocess.CalledProcessError): + return None + + +def _print_row(label: str, value: str) -> None: + print(f"{label:<44}{value:>16}") + + +def print_report( + metrics: dict[str, Any], + *, + workload: Workload, + endpoint: str, + sessions: int, + target_fps: float, +) -> None: + header = " LingBot-World realtime streaming benchmark " + print("\n{s:{c}^{n}}".format(s=header, n=60, c="=")) + _print_row("Endpoint:", endpoint) + _print_row("Geometry:", f"{workload.width}x{workload.height}") + _print_row("Chunks per session:", str(workload.num_chunks)) + _print_row("Sessions:", str(sessions)) + frames = workload.num_frames + _print_row("Real-time basis:", f"{target_fps:g} fps") + _print_row("Video seconds per session:", f"{frames / target_fps:.2f} ({frames} frames)") + if abs(target_fps - workload.fps) > 1e-9: + _print_row("Mux label sent to server:", f"{workload.fps} fps") + + if sessions == 1: + print("{s:{c}^{n}}".format(s=" Cadence ", n=60, c="-")) + if metrics.get("ttfc_ms") is not None: + _print_row("TTFC (session.start -> first chunk):", f"{metrics['ttfc_ms']:.1f} ms") + interval_all = metrics.get("interval_all") or {} + interval_steady = metrics.get("interval_steady") or {} + for title, summary in (("All chunks", interval_all), ("Steady state", interval_steady)): + if not summary: + continue + print(f" {title} (n={int(summary['count'])}):") + _print_row(" mean / median:", f"{summary['mean_ms']:.1f} / {summary['median_ms']:.1f} ms") + _print_row(" std / max:", f"{summary['std_ms']:.1f} / {summary['max_ms']:.1f} ms") + tail = f"{summary['p90_ms']:.0f} / {summary['p95_ms']:.0f} / {summary['p99_ms']:.0f} ms" + _print_row(" p90 / p95 / p99:", tail) + if summary["count"] < 100: + print( + " NOTE: fewer than 100 intervals; tail percentiles are descriptive, not reliable tail estimates." + ) + + print("{s:{c}^{n}}".format(s=" Real time ", n=60, c="-")) + _print_row("VIDEO_RTF (wall s / video s, <1 real time):", f"{metrics['video_rtf']:.3f}") + _print_row("VIDEO_RTFX (video s / wall s):", f"{metrics['video_rtfx']:.3f}") + if metrics.get("steady_video_rtf") is not None: + _print_row("VIDEO_RTF steady state:", f"{metrics['steady_video_rtf']:.3f}") + _print_row("Frames per second:", f"{metrics['frames_per_second']:.2f}") + _print_row("Chunk deadline:", f"{metrics['chunk_deadline_ms']:.1f} ms") + if metrics.get("slo_attainment") is not None: + _print_row( + "Steady chunks within deadline:", + f"{metrics['slo_attainment'] * 100:.1f}% ({metrics['slo_violations']} late)", + ) + if not metrics.get("steady_state_reached", False): + print( + f" NOTE: no chunk reached the steady state; the attention window needs " + f"{metrics['warmup_chunks']} chunks. Raise --num-chunks." + ) + + playback = metrics.get("playback") or {} + print("{s:{c}^{n}}".format(s=" Playback continuity ", n=60, c="-")) + _print_row("Start buffer:", f"{playback.get('buffer_chunks')} chunk(s)") + _print_row("Underruns:", str(playback.get("underrun_count"))) + _print_row("Total stall:", f"{playback.get('total_stall_ms', 0.0):.1f} ms") + _print_row("Stall ratio:", f"{playback.get('stall_ratio', 0.0) * 100:.2f}%") + if playback.get("first_underrun_chunk") is not None: + _print_row("First underrun at chunk:", str(playback["first_underrun_chunk"])) + else: + print("{s:{c}^{n}}".format(s=" Across sessions ", n=60, c="-")) + if metrics.get("ttfc_ms_mean") is not None: + _print_row("TTFC mean / max:", f"{metrics['ttfc_ms_mean']:.1f} / {metrics['ttfc_ms_max']:.1f} ms") + if metrics.get("steady_interval_ms_mean") is not None: + _print_row("Steady interval mean:", f"{metrics['steady_interval_ms_mean']:.1f} ms") + _print_row("Steady interval spread:", f"{metrics['steady_interval_ms_spread']:.1f} ms") + _print_row("VIDEO_RTF mean (lower is better):", f"{metrics['video_rtf_mean']:.3f}") + _print_row( + "VIDEO_RTF best / worst:", + f"{metrics['video_rtf_best']:.3f} / {metrics['video_rtf_worst']:.3f}", + ) + _print_row("Chunk deadline:", f"{metrics['chunk_deadline_ms']:.1f} ms") + _print_row("Underruns (all sessions):", str(metrics["underrun_count"])) + print("=" * 60) + + +def save_video(media: bytes, path: Path, *, fps: int) -> None: + from vllm_omni.diffusion.utils.media_utils import finalize_streaming_video_bytes + + playback_bytes = finalize_streaming_video_bytes(media, input_format="m4s", fps=fps) + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(path.suffix + ".tmp") + tmp_path.write_bytes(playback_bytes) + os.replace(tmp_path, path) + print(f"Saved {len(media)} streamed bytes -> {len(playback_bytes)} playback bytes at {path}") + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Benchmark LingBot-World 2.0 realtime streaming over WS /v1/realtime/video.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + endpoint = parser.add_argument_group("endpoint") + endpoint.add_argument("--host", default="127.0.0.1") + endpoint.add_argument("--port", type=int, default=8000) + endpoint.add_argument("--base-url", default=None, help="Full ws:// URL; overrides --host/--port.") + endpoint.add_argument("--model", default=DEFAULT_MODEL) + + workload = parser.add_argument_group("workload") + workload.add_argument("--workload", default=None, help="JSON rollout spec; overrides the built-in workload.") + workload.add_argument("--image", default=None, help="First frame: path or URL. Defaults to the shared vLLM asset.") + workload.add_argument("--prompt", dest="prompt_override", default=None, help=f"Default: {DEFAULT_PROMPT!r}") + workload.add_argument("--negative-prompt", default=None) + workload.add_argument("--num-chunks", type=int, default=16, help="AR blocks per session; ignored with --workload.") + workload.add_argument( + "--camera-pattern", + choices=CAMERA_PATTERNS, + default="forward", + help="Camera pattern for the built-in workload only; ignored with --workload.", + ) + workload.add_argument("--width", dest="width_override", type=int, default=None, help=f"Default: {DEFAULT_WIDTH}") + workload.add_argument("--height", dest="height_override", type=int, default=None, help=f"Default: {DEFAULT_HEIGHT}") + workload.add_argument("--fps", dest="fps_override", type=int, default=None, help=f"Default: {DEFAULT_FPS}") + workload.add_argument("--seed", dest="seed_override", type=int, default=None, help=f"Default: {DEFAULT_SEED}") + workload.add_argument("--flow-shift", type=float, default=None, help=f"Default: {DEFAULT_FLOW_SHIFT}") + + run = parser.add_argument_group("run") + run.add_argument("--sessions", type=int, default=1, help="Sequential measured rollouts.") + run.add_argument("--warmup-sessions", type=int, default=0, help="Unmeasured rollouts first (pays compile cost).") + run.add_argument( + "--warmup-chunks", + type=int, + default=DEFAULT_WARMUP_CHUNKS, + help="Chunks excluded from steady state; the attention window saturates after 6.", + ) + run.add_argument( + "--chunk-slo-ms", + type=float, + default=None, + help="Deadline a steady chunk must meet. Default: one chunk of video at --target-fps.", + ) + run.add_argument( + "--target-fps", + type=float, + default=None, + help=( + "Playback rate used for real-time factor, the chunk deadline, and the playback " + "simulation. The checkpoint declares no frame rate, so this is a consumption " + "choice, not a model property. Default: the --fps sent to the server." + ), + ) + run.add_argument("--playback-buffer-chunks", type=int, default=1, help="Chunks buffered before playback starts.") + run.add_argument("--first-chunk-timeout", type=float, default=900.0) + run.add_argument("--chunk-timeout", type=float, default=300.0) + run.add_argument("--ping-interval", type=float, default=20.0, help="session.ping cadence; 0 disables.") + + output = parser.add_argument_group("output") + output.add_argument("--output-json", default=None) + output.add_argument("--save-video", default=None, help="Write the last measured session's video here.") + output.add_argument("--print-chunks", action="store_true") + + args = parser.parse_args(argv) + if args.sessions < 1: + parser.error("--sessions must be at least 1.") + if args.warmup_sessions < 0: + parser.error("--warmup-sessions must be non-negative.") + if args.warmup_chunks < 0: + parser.error("--warmup-chunks must be non-negative.") + if args.playback_buffer_chunks < 1: + parser.error("--playback-buffer-chunks must be at least 1.") + if args.target_fps is not None and args.target_fps <= 0: + parser.error("--target-fps must be positive.") + if args.workload is None and args.num_chunks < 1: + parser.error("--num-chunks must be at least 1.") + return args + + +def endpoint_url(args: argparse.Namespace) -> str: + if args.base_url is not None: + base = args.base_url.rstrip("/") + for prefix, replacement in (("http://", "ws://"), ("https://", "wss://")): + if base.startswith(prefix): + base = replacement + base[len(prefix) :] + break + return base if base.endswith(_WS_PATH) else base + _WS_PATH + return f"ws://{args.host}:{args.port}{_WS_PATH}" + + +async def run_benchmark(args: argparse.Namespace) -> dict[str, Any]: + workload = build_workload(args) + url = endpoint_url(args) + # The mux label and the real-time basis are different questions; only the + # latter decides whether a run passes. + target_fps = args.target_fps if args.target_fps is not None else float(workload.fps) + if max(1, args.warmup_chunks) >= workload.num_chunks - 1: + print( + f"WARNING: --warmup-chunks={args.warmup_chunks} leaves no nonterminal steady-state chunk in a " + f"{workload.num_chunks}-chunk rollout; steady-state metrics will be empty.", + file=sys.stderr, + ) + + print(f"Connecting to {url}") + print( + f"Rollout: {workload.num_chunks} chunks, num_frames={workload.num_frames}, " + f"{workload.video_seconds:.2f}s of video" + ) + print( + f"Real-time basis: {target_fps:g} fps -> a steady 12-frame chunk is " + f"{steady_chunk_deadline_ms(target_fps):.1f} ms of video" + + ("" if args.target_fps is None else f" (mux label stays {workload.fps} fps)") + ) + + for index in range(args.warmup_sessions): + print(f"Warmup session {index + 1}/{args.warmup_sessions} (not measured)...") + await run_session( + url, + args.model, + workload, + first_chunk_timeout=args.first_chunk_timeout, + chunk_timeout=args.chunk_timeout, + ping_interval=args.ping_interval, + collect_media=False, + print_chunks=False, + ) + + session_metrics: list[dict[str, Any]] = [] + session_payloads: list[dict[str, Any]] = [] + last_result: SessionResult | None = None + for index in range(args.sessions): + if args.sessions > 1: + print(f"Session {index + 1}/{args.sessions}...") + result = await run_session( + url, + args.model, + workload, + first_chunk_timeout=args.first_chunk_timeout, + chunk_timeout=args.chunk_timeout, + ping_interval=args.ping_interval, + collect_media=args.save_video is not None, + print_chunks=args.print_chunks, + ) + last_result = result + metrics = compute_metrics( + result.records, + fps=target_fps, + warmup_chunks=args.warmup_chunks, + slo_ms=args.chunk_slo_ms, + playback_buffer_chunks=args.playback_buffer_chunks, + ttfc_s=result.ttfc_s, + session_wall_s=result.wall_s, + ) + session_metrics.append(metrics) + session_payloads.append( + { + "session_index": index, + "request_id": result.request_id, + "video_start_ms": (result.video_start_s * 1000.0) if result.video_start_s is not None else None, + "chunks": [record.to_dict() for record in result.records], + "metrics": metrics, + } + ) + + aggregate = aggregate_metrics(session_metrics, fps=target_fps) + print_report(aggregate, workload=workload, endpoint=url, sessions=args.sessions, target_fps=target_fps) + + if args.save_video is not None and last_result is not None: + save_video(last_result.media, Path(args.save_video), fps=workload.fps) + + document = { + "benchmark": "lingbot_world_realtime", + "timestamp": datetime.now(timezone.utc).isoformat(), + "endpoint": url, + "model": args.model, + "git_revision": _git_revision(), + "hostname": platform.node(), + "workload": workload.describe(), + "config": { + "sessions": args.sessions, + "warmup_sessions": args.warmup_sessions, + "warmup_chunks": args.warmup_chunks, + "camera_pattern": args.camera_pattern, + "chunk_slo_ms": args.chunk_slo_ms, + "target_fps": target_fps, + "mux_fps": workload.fps, + "playback_buffer_chunks": args.playback_buffer_chunks, + "workload_file": args.workload, + }, + "sessions": session_payloads, + "metrics": aggregate, + } + if args.output_json is not None: + path = Path(args.output_json) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n") + print(f"Wrote {path}") + return document + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + try: + asyncio.run(run_benchmark(args)) + except BenchmarkError as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/lingbot_world/configs/single_gpu_eager.yaml b/benchmarks/lingbot_world/configs/single_gpu_eager.yaml new file mode 100644 index 000000000..58f36e4f0 --- /dev/null +++ b/benchmarks/lingbot_world/configs/single_gpu_eager.yaml @@ -0,0 +1,45 @@ +# Benchmark deploy config: LingBot-World 2.0 on one GPU, eager. +# +# This is the portable smoke configuration -- it matches the shipped +# vllm_omni/deploy/lingbot_world_v2_stepwise.yaml so a number measured here is +# comparable to the default deployment. It is NOT a realtime configuration: one +# GPU runs all five forwards of every AR block serially and nothing is compiled, +# so expect a chunk interval several times the real-time deadline. +# +# Use configs/usp4_compiled.yaml for the realtime topology. +# +# vllm serve robbyant/lingbot-world-v2-14b-causal-fast-diffusers \ +# --omni --deploy-config benchmarks/lingbot_world/configs/single_gpu_eager.yaml \ +# --port 8000 + +pipeline: lingbot_world +async_chunk: false +distributed_executor_backend: mp +dtype: bfloat16 + +stages: + - stage_id: 0 + devices: "0" + # AR-Diffusion binds one session per runner invocation; the runner rejects + # anything larger. + max_num_seqs: 1 + enforce_eager: true + model_class_name: LingBotWorldCausalDMDPipeline + engine_backend: vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine + step_execution: true + streaming_output: true + default_sampling_params: + # The causal-fast checkpoint pins a four-step DMD schedule; this sizes the + # step budget before the pipeline is reached. + num_inference_steps: 4 + output_type: pil + extra_args: + flow_shift: 5.0 + model_config: + # The AR cache geometry is fixed at load time, so a benchmark request must + # ask for exactly this resolution. + ar_diffusion_height: 480 + ar_diffusion_width: 832 + ar_diffusion_kv_config: + gpu_memory_fraction: 0.6 + warmup_cudagraph: true diff --git a/benchmarks/lingbot_world/configs/usp4_compiled.yaml b/benchmarks/lingbot_world/configs/usp4_compiled.yaml new file mode 100644 index 000000000..ed71bc13e --- /dev/null +++ b/benchmarks/lingbot_world/configs/usp4_compiled.yaml @@ -0,0 +1,60 @@ +# Benchmark deploy config: LingBot-World 2.0 on four GPUs, Ulysses SP-4, compiled. +# +# This is the realtime topology. Two things differ from the single-GPU config, +# and both matter to the number the benchmark reports: +# +# parallel_config Pure Ulysses sequence parallelism over four ranks. The +# pipeline validates this: LingBot requires +# sequence_parallel_size == ulysses_degree, ring_degree and +# allgather_degree of 1, ulysses_mode "strict", and +# ulysses_a2a_permute disabled. Pipeline, CFG, and VAE +# parallel sizes above 1 are rejected. This config uses +# TP=1; the validator does not reject larger TP sizes. +# enforce_eager false puts LingBotDMDBlockRunner on the compiled path +# (regional torch.compile with cudagraph step marking). The +# first rollout therefore pays compile and capture cost -- +# use --warmup-sessions 1 before measured repetitions. +# Chunk warmup only excludes the attention-window ramp. +# +# Serve it under a GPU reservation, since it needs four cards: +# +# gpu run --gpus 4 -- vllm serve robbyant/lingbot-world-v2-14b-causal-fast-diffusers \ +# --omni --deploy-config benchmarks/lingbot_world/configs/usp4_compiled.yaml \ +# --port 8000 +# +# `devices` is the rank->card mapping inside the reservation, so it stays +# "0,1,2,3" no matter which physical cards CUDA_VISIBLE_DEVICES selected. + +pipeline: lingbot_world +async_chunk: false +distributed_executor_backend: mp +dtype: bfloat16 + +stages: + - stage_id: 0 + devices: "0,1,2,3" + max_num_seqs: 1 + enforce_eager: false + model_class_name: LingBotWorldCausalDMDPipeline + engine_backend: vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine + step_execution: true + streaming_output: true + parallel_config: + tensor_parallel_size: 1 + sequence_parallel_size: 4 + ulysses_degree: 4 + ring_degree: 1 + default_sampling_params: + num_inference_steps: 4 + output_type: pil + extra_args: + flow_shift: 5.0 + model_config: + ar_diffusion_height: 480 + ar_diffusion_width: 832 + ar_diffusion_kv_config: + # Each rank holds its own shard of the paged KV cache, so the per-card + # fraction can stay at the single-GPU value; lower it if the four-rank + # start OOMs on smaller cards. + gpu_memory_fraction: 0.6 + warmup_cudagraph: true diff --git a/benchmarks/lingbot_world/workload.py b/benchmarks/lingbot_world/workload.py new file mode 100644 index 000000000..4b59c8899 --- /dev/null +++ b/benchmarks/lingbot_world/workload.py @@ -0,0 +1,631 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Workload construction and metric math for the LingBot-World realtime benchmark. + +Nothing here imports torch, vllm, or websockets, so the arithmetic that decides +what a benchmark number *means* is unit-testable without a GPU or a server. + +Two model properties drive every formula in this module, both read from the +checkpoint's ``transformer/config.json`` rather than assumed: + +``num_frames_per_block = 3`` + One AR block commits three latent frames, so one streamed chunk is three + latent frames and a request's ``camera_action_script`` carries exactly one + three-entry action list per chunk. + +``sliding_window_num_frames = 18`` with ``sink_size = 9`` + Self-attention sees nine sink frames plus a nine-frame recent window, so the + key count a block attends over keeps growing until eighteen latent frames of + history exist -- six chunks. Chunk cost is therefore *not* stationary before + chunk six, and a mean taken over a short rollout reports a number no steady + session will ever produce. :data:`DEFAULT_WARMUP_CHUNKS` exists for that + reason and is not a round-number guess. + +The causal VAE expands the first latent frame to one pixel frame and every later +latent frame to four, so chunk zero carries nine pixel frames and every chunk +after it carries twelve. Video-duration math has to respect that asymmetry or +real-time factor comes out wrong on short rollouts. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# transformer.config.num_frames_per_block: latent frames committed per AR block. +FRAMES_PER_BLOCK = 3 +# vae_scale_factor_temporal: latent frames -> pixel frames, after the first. +TEMPORAL_COMPRESSION = 4 +# (sink_size 9 + recent window 9) / FRAMES_PER_BLOCK: chunks until the attention +# window saturates and per-chunk cost becomes stationary. +DEFAULT_WARMUP_CHUNKS = 6 + +DEFAULT_MODEL = "robbyant/lingbot-world-v2-14b-causal-fast-diffusers" +DEFAULT_PROMPT = "The camera moves slowly forward through the scene." +# The AR KV cache geometry is fixed at load time, so a request must ask for +# exactly the resolution the deploy config declares. +DEFAULT_WIDTH = 832 +DEFAULT_HEIGHT = 480 +DEFAULT_FPS = 16 +DEFAULT_FLOW_SHIFT = 5.0 +DEFAULT_SEED = 42 + +_HOLD = "hold" +_ACTION_KEYS = ("w", "a", "s", "d") + + +def num_frames_for_chunks(num_chunks: int) -> int: + """Pixel-frame count whose rollout is exactly ``num_chunks`` chunks. + + Inverse of :func:`chunks_for_num_frames`, which is the server's own + ``((num_frames - 1) // 4 + 1) // 3``. + """ + if num_chunks < 1: + raise ValueError("num_chunks must be at least 1.") + return (num_chunks * FRAMES_PER_BLOCK - 1) * TEMPORAL_COMPRESSION + 1 + + +def chunks_for_num_frames(num_frames: int) -> int: + """Chunks the server generates for ``num_frames``, mirroring its own formula.""" + if num_frames < 1: + raise ValueError("num_frames must be at least 1.") + return ((num_frames - 1) // TEMPORAL_COMPRESSION + 1) // FRAMES_PER_BLOCK + + +def pixel_frames_in_chunk(chunk_index: int) -> int: + """Pixel frames carried by one chunk. + + The causal decoder expands the session's opening latent frame once and every + later latent frame four times, so chunk zero is short by three frames. + """ + if chunk_index < 0: + raise ValueError("chunk_index must be non-negative.") + if chunk_index == 0: + return (FRAMES_PER_BLOCK - 1) * TEMPORAL_COMPRESSION + 1 + return FRAMES_PER_BLOCK * TEMPORAL_COMPRESSION + + +def camera_actions_for_chunk(pattern: str, chunk_index: int) -> list[list[str]]: + """One three-entry action list for ``chunk_index``. + + Camera actions select camera embeddings; they change what the world does, + not how much arithmetic it costs. The pattern therefore exists to keep a + benchmark rollout representative rather than to sweep a cost dimension. + """ + if chunk_index < 0: + raise ValueError("chunk_index must be non-negative.") + if pattern == "forward": + return [["w"] for _ in range(FRAMES_PER_BLOCK)] + if pattern == _HOLD: + return [[] for _ in range(FRAMES_PER_BLOCK)] + if pattern == "orbit": + key = _ACTION_KEYS[chunk_index % len(_ACTION_KEYS)] + return [[key] for _ in range(FRAMES_PER_BLOCK)] + raise ValueError(f"Unknown camera pattern {pattern!r}; expected one of {sorted(CAMERA_PATTERNS)}.") + + +CAMERA_PATTERNS = ("forward", "orbit", _HOLD) + + +def build_camera_script(num_chunks: int, pattern: str) -> list[list[list[str]]]: + """Full ``camera_action_script``: one action list per generated chunk.""" + if num_chunks < 1: + raise ValueError("num_chunks must be at least 1.") + return [camera_actions_for_chunk(pattern, index) for index in range(num_chunks)] + + +def _validate_camera_script(script: Sequence[Any]) -> list[list[list[str]]]: + validated: list[list[list[str]]] = [] + for chunk_index, actions in enumerate(script): + if not isinstance(actions, Sequence) or isinstance(actions, (str, bytes)): + raise ValueError(f"camera_action_script[{chunk_index}] must be a list of three action lists.") + if len(actions) != FRAMES_PER_BLOCK: + raise ValueError( + f"camera_action_script[{chunk_index}] must hold exactly {FRAMES_PER_BLOCK} action lists " + f"(got {len(actions)}); one AR block is {FRAMES_PER_BLOCK} latent frames." + ) + frames: list[list[str]] = [] + for frame_index, frame in enumerate(actions): + if not isinstance(frame, Sequence) or isinstance(frame, (str, bytes)): + raise ValueError(f"camera_action_script[{chunk_index}][{frame_index}] must be a list of strings.") + if any(not isinstance(action, str) for action in frame): + raise ValueError(f"camera_action_script[{chunk_index}][{frame_index}] must contain only strings.") + frames.append([str(action) for action in frame]) + validated.append(frames) + if not validated: + raise ValueError("camera_action_script must not be empty.") + return validated + + +@dataclass(frozen=True) +class PromptUpdate: + """A mid-rollout ``session.interaction`` scheduled on a chunk boundary.""" + + after_chunk: int + prompt: str + transition_chunks: int = 3 + + def __post_init__(self) -> None: + if self.after_chunk < 0: + raise ValueError("PromptUpdate.after_chunk must be non-negative.") + if not self.prompt.strip(): + raise ValueError("PromptUpdate.prompt must contain non-whitespace text.") + if self.transition_chunks < 0: + raise ValueError("PromptUpdate.transition_chunks must be non-negative.") + + def to_payload(self) -> dict[str, Any]: + return { + "type": "session.interaction", + "interaction": { + "event_id": f"chunk-{self.after_chunk}", + "event": {"prompt": self.prompt}, + "transition_chunks": self.transition_chunks, + }, + } + + +@dataclass(frozen=True) +class Workload: + """One rollout: what the benchmark asks the server to generate.""" + + prompt: str + image_reference: str + camera_script: list[list[list[str]]] + width: int = DEFAULT_WIDTH + height: int = DEFAULT_HEIGHT + fps: int = DEFAULT_FPS + seed: int = DEFAULT_SEED + flow_shift: float = DEFAULT_FLOW_SHIFT + negative_prompt: str | None = None + prompt_updates: tuple[PromptUpdate, ...] = () + extra_params: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if not self.prompt.strip(): + raise ValueError("Workload.prompt must contain non-whitespace text.") + if not self.image_reference: + raise ValueError("Workload.image_reference is required; LingBot-World is image-conditioned.") + if self.width <= 0 or self.height <= 0 or self.width % 16 or self.height % 16: + raise ValueError("Workload width/height must be positive multiples of 16.") + if self.fps <= 0: + raise ValueError("Workload.fps must be positive.") + object.__setattr__(self, "camera_script", _validate_camera_script(self.camera_script)) + seen_update_chunks: set[int] = set() + for update in self.prompt_updates: + if update.after_chunk in seen_update_chunks: + raise ValueError(f"Duplicate prompt update after chunk {update.after_chunk}; event IDs must be unique.") + seen_update_chunks.add(update.after_chunk) + if update.after_chunk >= self.num_chunks: + raise ValueError( + f"Prompt update after chunk {update.after_chunk} never fires in a {self.num_chunks}-chunk rollout." + ) + + @property + def num_chunks(self) -> int: + return len(self.camera_script) + + @property + def num_frames(self) -> int: + return num_frames_for_chunks(self.num_chunks) + + @property + def video_seconds(self) -> float: + return self.num_frames / self.fps + + def session_start_payload(self, model: str) -> dict[str, Any]: + """The ``session.start`` event for ``WS /v1/realtime/video``.""" + extra_params: dict[str, Any] = { + "flow_shift": self.flow_shift, + "camera_action_script": self.camera_script, + } + extra_params.update(self.extra_params) + payload: dict[str, Any] = { + "type": "session.start", + "model": model, + "prompt": self.prompt, + "image_reference": {"image_url": self.image_reference}, + "width": self.width, + "height": self.height, + "num_frames": self.num_frames, + "fps": self.fps, + "seed": self.seed, + "extra_params": extra_params, + } + if self.negative_prompt is not None: + payload["negative_prompt"] = self.negative_prompt + return payload + + def describe(self) -> dict[str, Any]: + return { + "num_chunks": self.num_chunks, + "num_frames": self.num_frames, + "width": self.width, + "height": self.height, + "fps": self.fps, + "seed": self.seed, + "flow_shift": self.flow_shift, + "video_seconds": self.video_seconds, + "prompt_updates": [ + { + "after_chunk": update.after_chunk, + "transition_chunks": update.transition_chunks, + } + for update in self.prompt_updates + ], + } + + +def default_workload( + *, + image_reference: str, + num_chunks: int, + camera_pattern: str = "forward", + prompt: str = DEFAULT_PROMPT, + width: int = DEFAULT_WIDTH, + height: int = DEFAULT_HEIGHT, + fps: int = DEFAULT_FPS, + seed: int = DEFAULT_SEED, + flow_shift: float = DEFAULT_FLOW_SHIFT, + negative_prompt: str | None = None, +) -> Workload: + """Built-in rollout: a fixed prompt and a generated camera script.""" + return Workload( + prompt=prompt, + image_reference=image_reference, + camera_script=build_camera_script(num_chunks, camera_pattern), + width=width, + height=height, + fps=fps, + seed=seed, + flow_shift=flow_shift, + negative_prompt=negative_prompt, + ) + + +def load_workload( + path: str | Path, + *, + image_resolver: Any = None, + overrides: Mapping[str, Any] | None = None, +) -> Workload: + """Read a rollout spec from JSON. + + ``image_resolver`` turns the spec's ``image`` field into an ``http(s)`` or + ``data:`` URL; the caller owns that because inlining a local file is I/O. + Keys in ``overrides`` that are not ``None`` win over the file, so CLI flags + stay meaningful next to ``--workload``. + """ + document = json.loads(Path(path).read_text()) + if not isinstance(document, dict): + raise ValueError("A workload file must contain a JSON object.") + + merged = dict(document) + for key, value in (overrides or {}).items(): + if value is not None: + merged[key] = value + + camera_script = merged.get("camera_action_script") + if camera_script is None: + num_chunks = merged.get("num_chunks") + if num_chunks is None: + raise ValueError("A workload file needs either camera_action_script or num_chunks.") + camera_script = build_camera_script(int(num_chunks), str(merged.get("camera_pattern", "forward"))) + + image = merged.get("image") or merged.get("image_reference") + if image is None: + raise ValueError("A workload file needs an image or image_reference field.") + image_reference = image_resolver(str(image)) if image_resolver is not None else str(image) + + updates_field = merged.get("prompt_updates") or () + prompt_updates = tuple( + PromptUpdate( + after_chunk=int(entry["after_chunk"]), + prompt=str(entry["prompt"]), + transition_chunks=int(entry.get("transition_chunks", 3)), + ) + for entry in updates_field + ) + + extra_params = merged.get("extra_params") or {} + if not isinstance(extra_params, Mapping): + raise ValueError("Workload extra_params must be a JSON object.") + + return Workload( + prompt=str(merged.get("prompt", DEFAULT_PROMPT)), + image_reference=image_reference, + camera_script=camera_script, + width=int(merged.get("width", DEFAULT_WIDTH)), + height=int(merged.get("height", DEFAULT_HEIGHT)), + fps=int(merged.get("fps", DEFAULT_FPS)), + seed=int(merged.get("seed", DEFAULT_SEED)), + flow_shift=float(merged.get("flow_shift", DEFAULT_FLOW_SHIFT)), + negative_prompt=merged.get("negative_prompt"), + prompt_updates=prompt_updates, + extra_params=dict(extra_params), + ) + + +# --------------------------------------------------------------------------- # +# Metrics +# --------------------------------------------------------------------------- # + + +@dataclass +class ChunkRecord: + """One media chunk as the client observed it.""" + + index: int + arrival_s: float + """Seconds from ``session.start`` being sent to this chunk's bytes arriving.""" + inter_arrival_s: float + """Seconds since the previous chunk; for chunk zero this equals ``arrival_s``.""" + byte_length: int + num_frames: int + + def to_dict(self) -> dict[str, Any]: + return { + "index": self.index, + "arrival_s": self.arrival_s, + "inter_arrival_ms": self.inter_arrival_s * 1000.0, + "byte_length": self.byte_length, + "num_frames": self.num_frames, + } + + +def percentile(values: Sequence[float], fraction: float) -> float: + """Linearly interpolated percentile, so a 10-sample run still reports a p95. + + Implemented here rather than pulled from numpy to keep this module importable + in a bare environment; the benchmark's own numbers must not depend on which + optional package happens to be installed. + """ + if not values: + raise ValueError("percentile() needs at least one value.") + if not 0.0 <= fraction <= 1.0: + raise ValueError("percentile fraction must be within [0, 1].") + ordered = sorted(values) + if len(ordered) == 1: + return ordered[0] + position = fraction * (len(ordered) - 1) + lower = int(position) + upper = min(lower + 1, len(ordered) - 1) + weight = position - lower + return ordered[lower] * (1.0 - weight) + ordered[upper] * weight + + +def _stddev(values: Sequence[float]) -> float: + if len(values) < 2: + return 0.0 + mean = sum(values) / len(values) + return (sum((value - mean) ** 2 for value in values) / (len(values) - 1)) ** 0.5 + + +def _latency_summary(values: Sequence[float]) -> dict[str, float]: + """Millisecond summary of a set of inter-arrival intervals.""" + if not values: + return {} + milliseconds = [value * 1000.0 for value in values] + return { + "count": float(len(milliseconds)), + "mean_ms": sum(milliseconds) / len(milliseconds), + "median_ms": percentile(milliseconds, 0.5), + "std_ms": _stddev(milliseconds), + "min_ms": min(milliseconds), + "p90_ms": percentile(milliseconds, 0.90), + "p95_ms": percentile(milliseconds, 0.95), + "p99_ms": percentile(milliseconds, 0.99), + "max_ms": max(milliseconds), + } + + +@dataclass(frozen=True) +class PlaybackResult: + """What a viewer would have seen, given a start buffer and a playback rate.""" + + underrun_count: int + total_stall_s: float + playback_span_s: float + first_underrun_chunk: int | None + + def to_dict(self) -> dict[str, Any]: + return { + "underrun_count": self.underrun_count, + "total_stall_ms": self.total_stall_s * 1000.0, + "playback_span_s": self.playback_span_s, + "stall_ratio": (self.total_stall_s / self.playback_span_s) if self.playback_span_s > 0 else 0.0, + "first_underrun_chunk": self.first_underrun_chunk, + } + + +def simulate_playback( + records: Sequence[ChunkRecord], + *, + fps: float, + buffer_chunks: int = 1, +) -> PlaybackResult: + """Replay arrivals against a wall clock and count stalls. + + Playback starts when ``buffer_chunks`` chunks have arrived and then consumes + video at ``fps``. A chunk that has not arrived by the moment the player needs + it is an underrun, and the player resumes only when the bytes land -- which + is what a viewer experiences, and what a single p99 number cannot tell you: + the same p99 is harmless behind a three-chunk buffer and fatal behind none. + """ + if fps <= 0: + raise ValueError("fps must be positive.") + if buffer_chunks < 1: + raise ValueError("buffer_chunks must be at least 1.") + if not records: + return PlaybackResult(0, 0.0, 0.0, None) + if buffer_chunks > len(records): + raise ValueError(f"buffer_chunks={buffer_chunks} exceeds the {len(records)} chunks received.") + + ordered = sorted(records, key=lambda record: record.index) + start = ordered[buffer_chunks - 1].arrival_s + cursor = start + underruns = 0 + total_stall = 0.0 + first_underrun: int | None = None + + for record in ordered: + if record.arrival_s > cursor: + stall = record.arrival_s - cursor + underruns += 1 + total_stall += stall + if first_underrun is None: + first_underrun = record.index + cursor = record.arrival_s + cursor += record.num_frames / fps + + return PlaybackResult( + underrun_count=underruns, + total_stall_s=total_stall, + playback_span_s=cursor - start, + first_underrun_chunk=first_underrun, + ) + + +def steady_chunk_deadline_ms(fps: float) -> float: + """Wall-clock budget a steady chunk must meet to hold real time. + + ``fps`` here is the rate the frames are *intended to be played at*, which the + checkpoint does not declare: nothing in ``model_index.json``, the scheduler + config, or the transformer config carries a frame rate, and the pipeline + never reads one. It is a property of how the output is consumed, so both + this deadline and real-time factor scale linearly with the choice and the + caller has to state it rather than inherit it from a muxing default. + """ + if fps <= 0: + raise ValueError("fps must be positive.") + return pixel_frames_in_chunk(1) / fps * 1000.0 + + +def compute_metrics( + records: Sequence[ChunkRecord], + *, + fps: float, + warmup_chunks: int = DEFAULT_WARMUP_CHUNKS, + slo_ms: float | None = None, + playback_buffer_chunks: int = 1, + ttfc_s: float | None = None, + session_wall_s: float | None = None, +) -> dict[str, Any]: + """Summarize one session. + + ``warmup_chunks`` are excluded from the steady-state block because the + attention window has not saturated before then (see the module docstring). + The terminal chunk also stays out of steady metrics because it skips + next-chunk preparation. All chunks stay in the playback simulation, which is + what a session really costs end to end. + """ + if not records: + raise ValueError("compute_metrics() needs at least one chunk record.") + if warmup_chunks < 0: + raise ValueError("warmup_chunks must be non-negative.") + + ordered = sorted(records, key=lambda record: record.index) + deadline_ms = slo_ms if slo_ms is not None else steady_chunk_deadline_ms(fps) + + all_intervals = [record.inter_arrival_s for record in ordered[1:]] + # The terminal chunk skips next-chunk preparation and is not representative + # of sustained generation. Keep it in end-to-end and playback metrics. + steady = [record for record in ordered[:-1] if record.index >= warmup_chunks] + steady_intervals = [record.inter_arrival_s for record in steady if record.index > 0] + + total_frames = sum(record.num_frames for record in ordered) + video_seconds = total_frames / fps + wall_seconds = session_wall_s if session_wall_s is not None else ordered[-1].arrival_s + + metrics: dict[str, Any] = { + "chunks_received": len(ordered), + "warmup_chunks": warmup_chunks, + "steady_chunks": len(steady_intervals), + "steady_state_reached": bool(steady_intervals), + "total_frames": total_frames, + "total_bytes": sum(record.byte_length for record in ordered), + "video_seconds": video_seconds, + "wall_seconds": wall_seconds, + # RTF is processing time over content duration, as vLLM-Omni computes it + # (`patch.py`: generation_s / video_duration; `compute_audio_rtf`: latency + # / audio_duration, "SLO red line < 1"). LOWER IS BETTER and below 1.0 is + # real time. RTFX is the reciprocal the repo reports under its own name, + # kept here because "fraction of real time achieved" reads better on a + # dashboard -- but the two must never be confused for one another. + "video_rtf": (wall_seconds / video_seconds) if video_seconds > 0 else 0.0, + "video_rtfx": (video_seconds / wall_seconds) if wall_seconds > 0 else 0.0, + "frames_per_second": (total_frames / wall_seconds) if wall_seconds > 0 else 0.0, + "chunk_deadline_ms": deadline_ms, + "interval_all": _latency_summary(all_intervals), + "interval_steady": _latency_summary(steady_intervals), + } + if ttfc_s is not None: + metrics["ttfc_ms"] = ttfc_s * 1000.0 + + if steady_intervals: + steady_frames = sum(record.num_frames for record in steady if record.index > 0) + steady_wall = sum(steady_intervals) + steady_video = steady_frames / fps + metrics["steady_video_rtf"] = (steady_wall / steady_video) if steady_video > 0 else 0.0 + metrics["steady_video_rtfx"] = (steady_video / steady_wall) if steady_wall > 0 else 0.0 + within = sum(1 for value in steady_intervals if value * 1000.0 <= deadline_ms) + metrics["slo_attainment"] = within / len(steady_intervals) + metrics["slo_violations"] = len(steady_intervals) - within + + metrics["playback"] = simulate_playback( + ordered, + fps=fps, + buffer_chunks=playback_buffer_chunks, + ).to_dict() + metrics["playback"]["buffer_chunks"] = playback_buffer_chunks + return metrics + + +def aggregate_metrics(sessions: Iterable[Mapping[str, Any]], *, fps: float) -> dict[str, Any]: + """Combine per-session metrics across sequential repeats. + + Steady intervals are pooled rather than averaging per-session means, so a + session that happened to produce fewer steady chunks does not carry the same + weight as a full one. + """ + entries = list(sessions) + if not entries: + raise ValueError("aggregate_metrics() needs at least one session.") + if len(entries) == 1: + single = dict(entries[0]) + single["sessions"] = 1 + return single + + deadline_ms = entries[0]["chunk_deadline_ms"] + if any(entry["chunk_deadline_ms"] != deadline_ms for entry in entries): + raise ValueError("Cannot aggregate sessions with different chunk deadlines.") + ttfcs = [float(entry["ttfc_ms"]) for entry in entries if entry.get("ttfc_ms") is not None] + # Lower is better, so "max" is the worst session, not the best. + rtfs = [float(entry["video_rtf"]) for entry in entries] + steady_means = [ + float(entry["interval_steady"]["mean_ms"]) for entry in entries if entry.get("interval_steady", {}).get("count") + ] + steady_count = sum(entry.get("interval_steady", {}).get("count", 0) for entry in entries) + steady_total_ms = sum( + entry["interval_steady"]["mean_ms"] * entry["interval_steady"]["count"] + for entry in entries + if entry.get("interval_steady", {}).get("count") + ) + return { + "sessions": len(entries), + "chunks_received": sum(int(entry["chunks_received"]) for entry in entries), + "total_frames": sum(int(entry["total_frames"]) for entry in entries), + "video_seconds": sum(float(entry["video_seconds"]) for entry in entries), + "wall_seconds": sum(float(entry["wall_seconds"]) for entry in entries), + "video_rtf_mean": sum(rtfs) / len(rtfs), + "video_rtf_best": min(rtfs), + "video_rtf_worst": max(rtfs), + "ttfc_ms_mean": (sum(ttfcs) / len(ttfcs)) if ttfcs else None, + "ttfc_ms_max": max(ttfcs) if ttfcs else None, + "steady_interval_ms_mean": (steady_total_ms / steady_count) if steady_count else None, + "steady_interval_ms_spread": (max(steady_means) - min(steady_means)) if steady_means else None, + "underrun_count": sum(int(entry["playback"]["underrun_count"]) for entry in entries), + "chunk_deadline_ms": deadline_ms, + } diff --git a/tests/benchmarks/test_lingbot_world_realtime.py b/tests/benchmarks/test_lingbot_world_realtime.py new file mode 100644 index 000000000..9926513b4 --- /dev/null +++ b/tests/benchmarks/test_lingbot_world_realtime.py @@ -0,0 +1,798 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Unit tests for the LingBot-World realtime streaming benchmark. + +The arithmetic is tested against the server's own formulas rather than against +hand-copied expectations, and the protocol handling runs against a real +WebSocket server that replays ``/v1/realtime/video`` with scripted delays -- so +a benchmark number can be wrong only if the server itself is, not because the +client parsed incorrectly an event or timed the wrong edge. +""" + +from __future__ import annotations + +# ruff: noqa: E402, I001 +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from benchmarks.lingbot_world.benchmark_lingbot_world_realtime import ( + BenchmarkError, + build_workload, + print_report, + endpoint_url, + parse_args, + resolve_image_reference, + run_session, +) +from benchmarks.lingbot_world.workload import ( + DEFAULT_WARMUP_CHUNKS, + FRAMES_PER_BLOCK, + ChunkRecord, + PromptUpdate, + Workload, + aggregate_metrics, + build_camera_script, + chunks_for_num_frames, + compute_metrics, + default_workload, + load_workload, + num_frames_for_chunks, + percentile, + pixel_frames_in_chunk, + simulate_playback, + steady_chunk_deadline_ms, +) + +_IMAGE = "data:image/png;base64,Zm9v" + + +def _workload(num_chunks: int = 8, fps: int = 16) -> Workload: + return default_workload(image_reference=_IMAGE, num_chunks=num_chunks, fps=fps) + + +# --------------------------------------------------------------------------- # +# Chunk and frame arithmetic +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("num_chunks", list(range(1, 33))) +def test_frame_count_round_trips_through_the_server_formula(num_chunks: int) -> None: + """``num_frames`` must land on a rollout of exactly the requested chunks. + + The server derives chunk count as ``((num_frames - 1) // 4 + 1) // 3``; if + the benchmark's inverse is off by one frame the run silently generates a + different number of blocks than it reports. + """ + assert chunks_for_num_frames(num_frames_for_chunks(num_chunks)) == num_chunks + + +def test_recipe_frame_counts_match_the_documented_chunk_counts() -> None: + # The recipe states three chunks for num_frames 33 and seven for 81. + assert chunks_for_num_frames(33) == 3 + assert chunks_for_num_frames(81) == 7 + assert num_frames_for_chunks(3) == 33 + assert num_frames_for_chunks(7) == 81 + + +def test_first_chunk_is_short_because_the_decoder_expands_it_once() -> None: + assert pixel_frames_in_chunk(0) == 9 + assert pixel_frames_in_chunk(1) == 12 + assert pixel_frames_in_chunk(7) == 12 + # A whole rollout's frames are the sum of its chunks' frames. + assert sum(pixel_frames_in_chunk(index) for index in range(5)) == num_frames_for_chunks(5) + + +def test_camera_script_holds_one_three_entry_action_list_per_chunk() -> None: + script = build_camera_script(4, "orbit") + assert len(script) == 4 + assert all(len(actions) == FRAMES_PER_BLOCK for actions in script) + # orbit advances through the action keys so successive chunks differ. + assert script[0] != script[1] + + +def test_unknown_camera_pattern_is_rejected() -> None: + with pytest.raises(ValueError, match="Unknown camera pattern"): + build_camera_script(2, "sideways") + + +# --------------------------------------------------------------------------- # +# Workload +# --------------------------------------------------------------------------- # + + +def test_session_start_payload_matches_the_documented_protocol() -> None: + workload = _workload(num_chunks=3) + payload = workload.session_start_payload("robbyant/lingbot-world-v2-14b-causal-fast-diffusers") + assert payload["type"] == "session.start" + assert payload["num_frames"] == 33 + assert payload["image_reference"] == {"image_url": _IMAGE} + assert payload["extra_params"]["flow_shift"] == 5.0 + assert len(payload["extra_params"]["camera_action_script"]) == 3 + + +def test_workload_rejects_a_camera_script_with_the_wrong_block_width() -> None: + with pytest.raises(ValueError, match="exactly 3 action lists"): + Workload(prompt="p", image_reference=_IMAGE, camera_script=[[["w"], ["w"]]]) + + +def test_workload_rejects_geometry_the_cache_cannot_serve() -> None: + with pytest.raises(ValueError, match="multiples of 16"): + Workload(prompt="p", image_reference=_IMAGE, camera_script=build_camera_script(1, "forward"), width=831) + + +def test_prompt_update_beyond_the_rollout_is_rejected() -> None: + with pytest.raises(ValueError, match="never fires"): + Workload( + prompt="p", + image_reference=_IMAGE, + camera_script=build_camera_script(2, "forward"), + prompt_updates=(PromptUpdate(after_chunk=5, prompt="later"),), + ) + + +@pytest.mark.parametrize("from_file", [False, True]) +def test_duplicate_prompt_update_boundaries_are_rejected(tmp_path: Path, from_file: bool) -> None: + updates = [ + {"after_chunk": 1, "prompt": "Rain begins"}, + {"after_chunk": 0, "prompt": "Clouds gather"}, + {"after_chunk": 1, "prompt": "Rain stops"}, + ] + with pytest.raises(ValueError, match="Duplicate prompt update after chunk 1"): + if from_file: + path = tmp_path / "rollout.json" + path.write_text(json.dumps({"image": _IMAGE, "num_chunks": 3, "prompt_updates": updates})) + build_workload(parse_args(["--workload", str(path)])) + else: + Workload( + prompt="p", + image_reference=_IMAGE, + camera_script=build_camera_script(3, "forward"), + prompt_updates=( + PromptUpdate(after_chunk=1, prompt="Rain begins"), + PromptUpdate(after_chunk=0, prompt="Clouds gather"), + PromptUpdate(after_chunk=1, prompt="Rain stops"), + ), + ) + + +def test_workload_file_is_loaded_and_cli_overrides_win(tmp_path: Path) -> None: + path = tmp_path / "rollout.json" + path.write_text( + json.dumps( + { + "prompt": "A quiet street", + "image": "https://example.invalid/frame.png", + "num_chunks": 4, + "camera_pattern": "hold", + "fps": 12, + "seed": 7, + "prompt_updates": [{"after_chunk": 1, "prompt": "Rain begins", "transition_chunks": 2}], + } + ) + ) + workload = load_workload(path, overrides={"fps": 24, "seed": None}) + assert workload.prompt == "A quiet street" + assert workload.num_chunks == 4 + assert workload.fps == 24, "an explicit CLI value must beat the file" + assert workload.seed == 7, "a None override must leave the file value alone" + assert workload.prompt_updates[0].transition_chunks == 2 + + +def test_workload_file_without_a_rollout_length_is_rejected(tmp_path: Path) -> None: + path = tmp_path / "rollout.json" + path.write_text(json.dumps({"image": "https://example.invalid/frame.png"})) + with pytest.raises(ValueError, match="camera_action_script or num_chunks"): + load_workload(path) + + +def test_local_image_is_inlined_as_a_data_url(tmp_path: Path) -> None: + image = tmp_path / "frame.png" + image.write_bytes(b"\x89PNG\r\n\x1a\n") + reference = resolve_image_reference(str(image)) + assert reference.startswith("data:image/png;base64,") + assert resolve_image_reference("https://example.invalid/a.png") == "https://example.invalid/a.png" + with pytest.raises(BenchmarkError, match="neither a URL nor a readable file"): + resolve_image_reference(str(tmp_path / "missing.png")) + + +# --------------------------------------------------------------------------- # +# Metric math +# --------------------------------------------------------------------------- # + + +def test_rtf_is_a_cost_not_a_speed() -> None: + """RTF must match vLLM-Omni's own formula, where lower is better. + + ``vllm_omni/metrics/definitions.py::compute_audio_rtf`` is + ``latency / duration`` with the docstring "SLO red line < 1", and + ``benchmarks/patch/patch.py`` sets ``video_rtf = generation_s / + video_duration``. Emitting the reciprocal under the same field name would + silently invert every dashboard that aggregates it, so the direction is + pinned here rather than left to a comment. + """ + # Two chunks, 9 + 12 = 21 frames at 12 fps = 1.75 s of video, generated in 3.5 s. + slow = compute_metrics(_records([1.0, 2.5]), fps=12, warmup_chunks=0, session_wall_s=3.5) + assert slow["video_seconds"] == pytest.approx(1.75) + assert slow["video_rtf"] == pytest.approx(2.0), "twice as slow as real time" + assert slow["video_rtfx"] == pytest.approx(0.5) + + fast = compute_metrics(_records([0.4, 0.475]), fps=12, warmup_chunks=0, session_wall_s=0.875) + assert fast["video_rtf"] == pytest.approx(0.5), "twice as fast as real time" + assert fast["video_rtf"] < 1.0 < slow["video_rtf"] + assert fast["video_rtf"] * fast["video_rtfx"] == pytest.approx(1.0) + + +def test_steady_rtf_is_the_chunk_interval_over_its_deadline() -> None: + """A steady chunk's RTF is just its interval divided by its video duration.""" + metrics = compute_metrics(_records([1.0] + [1.357] * 15), fps=12, warmup_chunks=6) + # 12 frames at 12 fps = 1000 ms of video per steady chunk. + assert metrics["chunk_deadline_ms"] == pytest.approx(1000.0) + assert metrics["steady_video_rtf"] == pytest.approx(1.357, abs=1e-3) + + +def test_percentile_interpolates_so_short_runs_still_report_tails() -> None: + values = [10.0, 20.0, 30.0, 40.0] + assert percentile(values, 0.0) == 10.0 + assert percentile(values, 1.0) == 40.0 + assert percentile(values, 0.5) == 25.0 + assert percentile([5.0], 0.99) == 5.0 + + +def _records(intervals: list[float]) -> list[ChunkRecord]: + """One record per entry; ``intervals[0]`` is the TTFC, the rest are gaps.""" + records: list[ChunkRecord] = [] + arrival = 0.0 + for index, interval in enumerate(intervals): + arrival += interval + records.append( + ChunkRecord( + index=index, + arrival_s=arrival, + inter_arrival_s=interval, + byte_length=1000, + num_frames=pixel_frames_in_chunk(index), + ) + ) + return records + + +def test_steady_state_excludes_the_unsaturated_attention_window() -> None: + """Warmup chunks must not drag the steady-state mean down. + + The first six chunks attend over a growing window, so they are genuinely + cheaper. A benchmark that averages them in reports a cadence the session + will never sustain. + """ + intervals = [1.0] + [0.2] * (DEFAULT_WARMUP_CHUNKS - 1) + [0.8] * 4 + metrics = compute_metrics(_records(intervals), fps=16, warmup_chunks=DEFAULT_WARMUP_CHUNKS) + assert metrics["interval_steady"]["count"] == 3 + assert metrics["interval_steady"]["mean_ms"] == pytest.approx(800.0) + assert metrics["interval_all"]["mean_ms"] < metrics["interval_steady"]["mean_ms"] + + +def test_short_rollouts_report_that_steady_state_was_never_reached() -> None: + metrics = compute_metrics(_records([1.0, 0.5, 0.5]), fps=16, warmup_chunks=DEFAULT_WARMUP_CHUNKS) + assert metrics["steady_state_reached"] is False + assert metrics["interval_steady"] == {} + assert "slo_attainment" not in metrics + + +def test_real_time_factor_and_deadline_use_the_true_frame_counts() -> None: + # Eight chunks: 9 + 7*12 = 93 frames at 16 fps = 5.8125 s of video. + metrics = compute_metrics(_records([1.0] + [0.5] * 7), fps=16, warmup_chunks=0, session_wall_s=5.8125) + assert metrics["total_frames"] == 93 + assert metrics["video_seconds"] == pytest.approx(5.8125) + assert metrics["video_rtf"] == pytest.approx(1.0) + assert metrics["video_rtfx"] == pytest.approx(1.0) + # A steady chunk carries 12 frames, so real time allows 750 ms at 16 fps. + assert metrics["chunk_deadline_ms"] == pytest.approx(750.0) + assert steady_chunk_deadline_ms(12) == pytest.approx(1000.0) + + +def test_slo_attainment_counts_only_steady_chunks() -> None: + intervals = [1.0] + [0.1] * 5 + [0.7, 0.9, 0.7, 0.9] + metrics = compute_metrics(_records(intervals), fps=16, warmup_chunks=6, slo_ms=750.0) + assert metrics["slo_attainment"] == pytest.approx(2 / 3) + assert metrics["slo_violations"] == 1 + + +def test_playback_simulation_finds_stalls_a_percentile_would_hide() -> None: + """One late chunk behind a one-chunk buffer is a visible stall.""" + # 12 frames at 16 fps = 750 ms of playback per chunk; chunk 3 takes 2 s. + intervals = [1.0, 0.5, 0.5, 2.0, 0.5] + result = simulate_playback(_records(intervals), fps=16, buffer_chunks=1) + assert result.underrun_count >= 1 + assert result.first_underrun_chunk == 3 + assert result.total_stall_s > 0.0 + + +def test_a_deeper_buffer_absorbs_the_same_jitter() -> None: + intervals = [1.0, 0.5, 0.5, 1.4, 0.2, 0.2] + fast = simulate_playback(_records(intervals), fps=16, buffer_chunks=1) + buffered = simulate_playback(_records(intervals), fps=16, buffer_chunks=3) + assert buffered.underrun_count < fast.underrun_count + + +def test_playback_rejects_a_buffer_deeper_than_the_rollout() -> None: + with pytest.raises(ValueError, match="exceeds"): + simulate_playback(_records([1.0, 0.5]), fps=16, buffer_chunks=9) + + +def test_aggregate_pools_sessions_and_reports_spread() -> None: + fast = compute_metrics(_records([1.0] + [0.1] * 5 + [0.6] * 4), fps=16, warmup_chunks=6) + slow = compute_metrics(_records([1.0] + [0.1] * 5 + [0.9] * 4), fps=16, warmup_chunks=6) + aggregate = aggregate_metrics([fast, slow], fps=16) + assert aggregate["sessions"] == 2 + assert aggregate["steady_interval_ms_mean"] == pytest.approx(750.0) + assert aggregate["steady_interval_ms_spread"] == pytest.approx(300.0) + assert aggregate["video_rtf_best"] <= aggregate["video_rtf_mean"] <= aggregate["video_rtf_worst"] + + +def test_single_session_aggregate_is_the_session_itself() -> None: + metrics = compute_metrics(_records([1.0] + [0.5] * 3), fps=16, warmup_chunks=0) + aggregate = aggregate_metrics([metrics], fps=16) + assert aggregate["sessions"] == 1 + assert aggregate["video_rtf"] == metrics["video_rtf"] + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # + + +def test_endpoint_url_normalizes_http_and_appends_the_ws_path() -> None: + assert endpoint_url(parse_args([])) == "ws://127.0.0.1:8000/v1/realtime/video" + assert endpoint_url(parse_args(["--host", "h", "--port", "9"])) == "ws://h:9/v1/realtime/video" + args = parse_args(["--base-url", "http://example.invalid:8099"]) + assert endpoint_url(args) == "ws://example.invalid:8099/v1/realtime/video" + args = parse_args(["--base-url", "ws://example.invalid:8099/v1/realtime/video"]) + assert endpoint_url(args) == "ws://example.invalid:8099/v1/realtime/video" + + +def test_cli_rejects_a_zero_chunk_buffer() -> None: + with pytest.raises(SystemExit): + parse_args(["--playback-buffer-chunks", "0"]) + with pytest.raises(SystemExit): + parse_args(["--target-fps", "0"]) + + +def test_the_real_time_basis_is_independent_of_the_mux_label() -> None: + """The checkpoint declares no frame rate, so the basis must be statable. + + The same measured cadence has RTF 1.809 at 16 fps and 1.357 at 12 fps; + tying that verdict to the fps sent to the muxer would let a playback label + silently decide whether a run passes. + """ + records = _records([1.0] + [1.357] * 15) + at_16 = compute_metrics(records, fps=16, warmup_chunks=6) + at_12 = compute_metrics(records, fps=12, warmup_chunks=6) + assert at_16["chunk_deadline_ms"] == pytest.approx(750.0) + assert at_12["chunk_deadline_ms"] == pytest.approx(1000.0) + # RTF is cost, so the harsher 16 fps basis scores WORSE (higher). + assert at_16["video_rtf"] > at_12["video_rtf"] + # Same wall clock, same bytes: only the basis moved. + assert at_16["video_rtf"] / at_12["video_rtf"] == pytest.approx(16 / 12) + assert parse_args([]).target_fps is None, "default defers to the workload fps" + + +# --------------------------------------------------------------------------- # +# Protocol handling against a scripted server +# --------------------------------------------------------------------------- # + + +def _serve(script): + """Return an async context manager yielding a ws:// URL for ``script``.""" + from websockets.asyncio.server import Server, serve + + class _Server: + def __init__(self) -> None: + self.server: Server | None = None + self.start_payloads: list[dict] = [] + + async def __aenter__(self) -> str: + async def handler(websocket) -> None: + self.start_payloads.append(json.loads(await websocket.recv())) + await script(websocket, self.start_payloads[-1]) + + self.server = await serve(handler, "127.0.0.1", 0) + port = next(iter(self.server.sockets)).getsockname()[1] + return f"ws://127.0.0.1:{port}/v1/realtime/video" + + async def __aexit__(self, *exc) -> None: + assert self.server is not None + self.server.close() + await self.server.wait_closed() + + return _Server() + + +async def _emit_rollout(websocket, payload, *, intervals=None, chunks=None, trailer=True) -> None: + """Replay the documented event order: start, metadata+binary per chunk, done.""" + num_chunks = chunks if chunks is not None else len(payload["extra_params"]["camera_action_script"]) + await websocket.send(json.dumps({"type": "video.start", "request_id": "req-1", "format": "m4s"})) + for index in range(num_chunks): + if intervals is not None: + await asyncio.sleep(intervals[index]) + frames = pixel_frames_in_chunk(index) + body = bytes([index % 256]) * (100 + index) + await websocket.send( + json.dumps( + { + "type": "video.chunk_metadata", + "request_id": "req-1", + "kind": "media", + "num_frames": frames, + "byte_length": len(body), + "generation_chunk_index": index, + } + ) + ) + await websocket.send(body) + if trailer: + tail = b"\x00" * 8 + await websocket.send( + json.dumps( + { + "type": "video.chunk_metadata", + "request_id": "req-1", + "kind": "trailer", + "num_frames": 0, + "byte_length": len(tail), + "generation_chunk_index": None, + } + ) + ) + await websocket.send(tail) + await websocket.send(json.dumps({"type": "session.done", "stopped": False, "chunks": num_chunks})) + + +def _run(coro): + return asyncio.run(asyncio.wait_for(coro, timeout=30)) + + +def test_session_records_one_chunk_per_block_and_ignores_the_trailer() -> None: + workload = _workload(num_chunks=4) + + async def scenario() -> None: + server = _serve(_emit_rollout) + url = await server.__aenter__() + try: + result = await run_session( + url, + "model", + workload, + first_chunk_timeout=10, + chunk_timeout=10, + ping_interval=0, + collect_media=True, + print_chunks=False, + ) + finally: + await server.__aexit__(None, None, None) + + assert [record.index for record in result.records] == [0, 1, 2, 3] + assert [record.num_frames for record in result.records] == [9, 12, 12, 12] + assert result.request_id == "req-1" + assert result.ttfc_s is not None and result.ttfc_s > 0 + # The trailer's bytes are kept for remuxing but carry no chunk record. + assert len(result.media) == sum(record.byte_length for record in result.records) + 8 + assert server.start_payloads[0]["num_frames"] == workload.num_frames + + _run(scenario()) + + +def test_measured_intervals_track_the_server_cadence() -> None: + workload = _workload(num_chunks=3) + + async def script(websocket, payload) -> None: + await _emit_rollout(websocket, payload, intervals=[0.05, 0.30, 0.05]) + + async def scenario() -> None: + server = _serve(script) + url = await server.__aenter__() + try: + result = await run_session( + url, + "model", + workload, + first_chunk_timeout=10, + chunk_timeout=10, + ping_interval=0, + collect_media=False, + print_chunks=False, + ) + finally: + await server.__aexit__(None, None, None) + + intervals = [record.inter_arrival_s for record in result.records] + assert intervals[1] == pytest.approx(0.30, abs=0.15) + assert intervals[2] < intervals[1], "a fast chunk must not inherit the slow one's interval" + assert result.records[0].arrival_s == pytest.approx(result.ttfc_s) + + _run(scenario()) + + +def test_a_short_rollout_fails_loudly_instead_of_reporting_a_partial_cadence() -> None: + workload = _workload(num_chunks=4) + + async def script(websocket, payload) -> None: + await _emit_rollout(websocket, payload, chunks=2) + + async def scenario() -> None: + server = _serve(script) + url = await server.__aenter__() + try: + with pytest.raises(BenchmarkError, match="Expected 4 chunks"): + await run_session( + url, + "model", + workload, + first_chunk_timeout=10, + chunk_timeout=10, + ping_interval=0, + collect_media=False, + print_chunks=False, + ) + finally: + await server.__aexit__(None, None, None) + + _run(scenario()) + + +def test_a_server_error_event_aborts_the_session() -> None: + workload = _workload(num_chunks=2) + + async def script(websocket, payload) -> None: + await websocket.send(json.dumps({"type": "error", "message": "resolution mismatch"})) + + async def scenario() -> None: + server = _serve(script) + url = await server.__aenter__() + try: + with pytest.raises(BenchmarkError, match="resolution mismatch.*requested 2 chunks"): + await run_session( + url, + "model", + workload, + first_chunk_timeout=10, + chunk_timeout=10, + ping_interval=0, + collect_media=False, + print_chunks=False, + ) + finally: + await server.__aexit__(None, None, None) + + _run(scenario()) + + +def test_a_stalled_server_times_out_rather_than_hanging() -> None: + workload = _workload(num_chunks=2) + + async def script(websocket, payload) -> None: + await websocket.send(json.dumps({"type": "video.start", "request_id": "req-1", "format": "m4s"})) + await asyncio.sleep(5) + + async def scenario() -> None: + server = _serve(script) + url = await server.__aenter__() + try: + with pytest.raises(BenchmarkError, match="No media chunk for"): + await run_session( + url, + "model", + workload, + first_chunk_timeout=0.4, + chunk_timeout=0.4, + ping_interval=0, + collect_media=False, + print_chunks=False, + ) + finally: + await server.__aexit__(None, None, None) + + _run(scenario()) + + +def test_keepalive_pings_reach_the_server_during_a_slow_first_chunk() -> None: + """A compiled first block can outlast the server's stall timeout.""" + workload = _workload(num_chunks=1) + seen: list[str] = [] + + async def script(websocket, payload) -> None: + await websocket.send(json.dumps({"type": "video.start", "request_id": "req-1", "format": "m4s"})) + + async def drain() -> None: + async for message in websocket: + event = json.loads(message) + seen.append(event["type"]) + if event["type"] == "session.ping": + await websocket.send(json.dumps({"type": "session.pong"})) + + drainer = asyncio.create_task(drain()) + await asyncio.sleep(0.35) + await _emit_rollout(websocket, payload, trailer=False) + drainer.cancel() + + async def scenario() -> None: + server = _serve(script) + url = await server.__aenter__() + try: + result = await run_session( + url, + "model", + workload, + first_chunk_timeout=10, + chunk_timeout=10, + ping_interval=0.1, + collect_media=False, + print_chunks=False, + ) + finally: + await server.__aexit__(None, None, None) + assert len(result.records) == 1 + assert "session.ping" in seen + + _run(scenario()) + + +def test_prompt_updates_are_sent_on_their_chunk_boundary() -> None: + workload = Workload( + prompt="start", + image_reference=_IMAGE, + camera_script=build_camera_script(3, "forward"), + prompt_updates=(PromptUpdate(after_chunk=0, prompt="Rain begins", transition_chunks=1),), + ) + received: list[dict] = [] + + async def script(websocket, payload) -> None: + async def drain() -> None: + async for message in websocket: + received.append(json.loads(message)) + + drainer = asyncio.create_task(drain()) + await _emit_rollout(websocket, payload, intervals=[0.02, 0.15, 0.02], trailer=False) + await asyncio.sleep(0.05) + drainer.cancel() + + async def scenario() -> None: + server = _serve(script) + url = await server.__aenter__() + try: + await run_session( + url, + "model", + workload, + first_chunk_timeout=10, + chunk_timeout=10, + ping_interval=0, + collect_media=False, + print_chunks=False, + ) + finally: + await server.__aexit__(None, None, None) + interactions = [event for event in received if event.get("type") == "session.interaction"] + assert len(interactions) == 1 + assert interactions[0]["interaction"]["event"]["prompt"] == "Rain begins" + assert interactions[0]["interaction"]["transition_chunks"] == 1 + + _run(scenario()) + + +@pytest.mark.parametrize("delivered", [0, 1]) +def test_pongs_do_not_extend_media_deadlines(delivered) -> None: + seen = [] + + async def script(websocket, payload) -> None: + if delivered: + await websocket.send( + json.dumps( + { + "type": "video.chunk_metadata", + "kind": "media", + "generation_chunk_index": 0, + "num_frames": 9, + } + ) + ) + await websocket.send(b"media") + async for message in websocket: + seen.append(json.loads(message)["type"]) + await websocket.send(json.dumps({"type": "session.pong"})) + + async def scenario() -> None: + async with _serve(script) as url: + with pytest.raises(BenchmarkError, match=f"after {delivered} chunk"): + await asyncio.wait_for( + run_session( + url, + "model", + _workload(num_chunks=2), + first_chunk_timeout=0.25, + chunk_timeout=0.25, + ping_interval=0.03, + collect_media=False, + print_chunks=False, + ), + timeout=2, + ) + assert "session.ping" in seen + + _run(scenario()) + + +def test_terminal_chunk_is_only_excluded_from_steady_metrics() -> None: + records = _records([1.0] + [0.1] * 5 + [1.2, 0.8, 0.1]) + metrics = compute_metrics(records, fps=12, warmup_chunks=6) + assert metrics["steady_chunks"] == 2 + assert metrics["interval_steady"]["mean_ms"] == pytest.approx(1000) + assert metrics["steady_video_rtf"] == pytest.approx(1) + assert metrics["slo_attainment"] == pytest.approx(0.5) + assert metrics["interval_all"]["count"] == 8 + assert metrics["total_frames"] == 105 + assert metrics["wall_seconds"] == pytest.approx(records[-1].arrival_s) + assert metrics["playback"]["total_stall_ms"] == pytest.approx( + simulate_playback(records, fps=12).total_stall_s * 1000 + ) + + +def test_terminal_chunk_alone_does_not_establish_steady_state() -> None: + metrics = compute_metrics(_records([1.0] * 7), fps=12, warmup_chunks=6) + assert not metrics["steady_state_reached"] + assert "steady_video_rtf" not in metrics + + +def test_aggregate_preserves_custom_deadline_and_weights_intervals() -> None: + fast = compute_metrics(_records([1.0, 0.5, 0.1]), fps=12, warmup_chunks=0, slo_ms=700) + slow = compute_metrics(_records([1.0, 0.9, 0.9, 0.9, 0.1]), fps=12, warmup_chunks=0, slo_ms=700) + aggregate = aggregate_metrics([fast, slow], fps=12) + assert aggregate["chunk_deadline_ms"] == 700 + assert aggregate["steady_interval_ms_mean"] == pytest.approx(800) + slow["chunk_deadline_ms"] = 800 + with pytest.raises(ValueError, match="different chunk deadlines"): + aggregate_metrics([fast, slow], fps=12) + + +@pytest.mark.parametrize( + "flags, expected_shift, expected_negative", + [ + ([], 7.0, "file negative"), + (["--flow-shift", "5", "--negative-prompt", "CLI negative"], 5.0, "CLI negative"), + (["--negative-prompt", ""], 7.0, ""), + ], +) +def test_cli_workload_generation_overrides(tmp_path, flags, expected_shift, expected_negative) -> None: + path = tmp_path / "workload.json" + path.write_text( + json.dumps( + { + "image": _IMAGE, + "num_chunks": 3, + "flow_shift": 7.0, + "negative_prompt": "file negative", + } + ) + ) + workload = build_workload(parse_args(["--workload", str(path), *flags])) + assert workload.flow_shift == expected_shift + assert workload.negative_prompt == expected_negative + + +def test_builtin_workload_keeps_default_flow_shift() -> None: + assert build_workload(parse_args(["--image", _IMAGE])).flow_shift == 5.0 + + +def test_short_sample_report_qualifies_tail_percentiles(capsys) -> None: + metrics = compute_metrics(_records([1.0] * 16), fps=12) + print_report(metrics, workload=_workload(num_chunks=16), endpoint="ws://test", sessions=1, target_fps=12) + assert "not reliable tail estimates" in capsys.readouterr().out From 44ea3688433cb068a398e38067a493bff043a8c6 Mon Sep 17 00:00:00 2001 From: WeiQing Chen <40507679+david6666666@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:18:28 +0800 Subject: [PATCH 10/21] [Doc] Update vLLM-Omni WeChat QR code (#7911) Signed-off-by: David Chen <530634352@qq.com> --- docs/assets/WeChat.jpg | Bin 273418 -> 249585 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/WeChat.jpg b/docs/assets/WeChat.jpg index f55fa90f30101c93944efba8b11deb08f0dc50a5..925d77a054a0d7e4df902446ee70dcd74903e030 100644 GIT binary patch literal 249585 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

d)qH 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 j54!FBnyn5yTnT&O zr`Mt+!TM1nXsG)fTjA0^Ro+o0swJnv!(Y#ha==V_wIIs$S@|x*f>GbaO{g>ye#jj) z_{$P#HgZOKnJ&yMX8$cNG{rr)j_SS5PmuBD1_BHQ#D5q*)fV*R%{EEjneSM(9l9@x{S>itsnelhV=~lhN>U=g)tN1|HX((N1M~>J}=^ zzRsifsP5Iw97yF=y&ZoZg9iFOIuwzhIf~K_%e4XLnQzs!iHkGSWsK9zV z&xrTy3Y#@JemITZ`rtAp^tsgA|C|apZ;iLiu$j)pvDC!ZNZ1#HKbPqn?uuB&ye`!C zvWAH~T}ArB8+33rjL9wVO<*S2(QvY&trfLs=ECT^QL5t-szh?Zsx>vhgy{{qe3Na&;EfHw6&)SJr*89v zirN%{>S#rH3T&$=xnpr4YE9HZxNTjF(}O%f45!U=X`07p1wRULoCxb&gPS8^LmI_z zsftUhe$4e*@cKV#M0Am`SH%PX7WuPluHqlgF~aFmJPk#Eyq*9zEwrTgeSg|$Oq|{? zrn>q?{`tgRf!!+=efpJU2^xmb9+@7d=ry%m^}rMBC+@gTuJp(h7ISmziPHs(Q>x$o z%#yIiuu#@QAT5f13|zy)mug9`l)D$(Flf;ChNJ3-5)Za=azWdXem3jVIZ z0e^pCbIhQ1Xzg&vK7(7({Gkb$WRB8yl-vqw61rjEd@ECzylv!B#p(-J2=!J$+iT^} zi>EyemWVRzYxB%lF(s*~Xi-WNd#bya^8Qf|3O%HQybEkbdmvKRsWgnkLDZO82wzf!gSNkL$&HQdWcIVb7vE{Yq|5H zD=0-G3c|pZBfFGb0;PZiX8s3x;0}c(h;k+1YRG#49o3A$S<}JOHMP?#BRghE{5&#L{8$yxqf1vWb5ybzCUV zhu|XJZb>!e`pBdBS|S}*iVdMAY;7kfQ8`81MOW|)oF1pi$+N@Xo#aJB!_MNaVjfRC zPm37&;2pZRJ-wPkE}N|RSkYVX`^MngEVFA6CSfrfRA6Bzgb9hEO&6B{)!GJ9v)1~A zqc!}~5+R*HoszRWpD~h`@hP1dLGuWJyX{sp;Kgd`RoKihg8|cwzTWDe5anw2J-6*Mg1&S@L zEUlL#SHTSe+`8RaB9{t3Kkv*)|ZWahm;BD4z~sAeG6p?x4IYCr9`7NjpgIJQY;%7b#nUBYbnHdkyuY>=tS^zG9zKapTs4RxXskR8St64CHb$h9a55s?~zO zYoSy-Umcq0cq`}IXuc_kf*Bm{tH34Eh@bR!rDC?dnqX;DFO6MP__lHXKt{AqTTU_S_!(na1z;dR;7>iMiEjXdFzP3*4v)^YsSVZPK;J1|c- z$IRo7Q7IVOP`9Avk-A#fw+GwF4A|atu~9t^UJ`X(4KDG)eNw;vswa*bZ|DX_>fuWo zb~{j0X>mRh;=0^n9$#rutB)T5X0RPUcoV(0Uoa>PvauJKYkf!#NYno{QmAiG4$tjW zr zA>GE+f?9V0H#?tnW%4wZ3stOj5i5^ih1e8~b;GUs{Fo&St`bJe05wGjN`Pq1+^%$P znKNNxuUkHx2>M3m&7vUs8g_T{>$!E!YvzzWlSRkjTO7O0G&b*ta+PO~@E~9eDtLg^ z7NF-E-k^bT&nj{LQrxR%6OE;E>8wendf-Izu_3V+8Kj%_)oRrzm5lS|)@DU#foGcK z>+I{QbjyCefoKtVMeavR z>}LLJLIrHehk-HU`gmx|QO*eOc#+P_qgkz&k$vGZQ-z&!S<3!;>R&bV_b`%!gx%t&Ut1kHjZa$}RUeGu0*OYnk_JoJtVaw}Bqu9tB! z!rwR6!&0POdJmKPO~~D|-kFIZL&4@pj-dMt3r2kMCFyO#sM`o+3&si7I^MladJZ_* zdFMoDs%0u?SW1~je`}`ymcnKR1#u}{ZP<9aL-8yBUmw(7MHbI~QPEE`nat=v-0vC0 zUpJ$%9SrJ1OJonDe~<$UV`Z1Na%WH=3L1>EXu#}RaucO@c6NE@l;yk!AXMS-v7Kv5 z$f0a4rR;Z&HBZ=eDZ(=KYj)}m8Wp!*152)P+~&)VmOhu`;VgbM!aHBt)%SK*yevmo zX?Jt5lRr}S^Yxh@u931`L(NxZKKa8ywB(=+@c@xaSYbm4hmNnM^H(oI&p0! zE&u4wca<%ZWm@8rbpI&MrEcCfK);W1557|EE)v`dx$jg~qpi}FkxN^IZ<;;Kti0jxh=N|UYHRykQR83#=|B5SEn0T^2xen+AUv)JU$1U4GcDD#hEz6$;mZ_uyb>dq zJn+1M?3Zn=Y>=6{w==^$xw18X-yH>VdbOLB69wJN)OA*5bF{S^SZ~rWm0Fr0ne**N zGc}iQR8AN>>Sj*bJ{xjf2|v|6vl0Brd!*ZwRX>4QAdqEcgG3b319RJbf(uzMUP^2? z=#W68%+S5yRK(bk31^kJnX{o@PE}+3j;n&rrioKHIOxWT*$ss<0%eAu@$w66*|rf6 zM>$Y+_;{hY9Ymr-G98XBeU#g=9C-R;iy4B>J&YJ!0XFI=d_Fp3ZnS-oLATnA_IPWO z#%bXd3(>tf|Kn%7v2?4C$q05U-mvkd z=5AN^{m~-nqCkO59QqKR>&{Wo#2s!<~pyf zr1LvK*~{5H(8w9)9_kBY}+VINHeb5LK_=jUaF#CxfM`k(onp@HvH*3{jhS2 zQMwFi+2YXwq;U{|HX2P9hKS!o|93(KqC0Q8RjS^qwD1XcBVnMs2uRX%MG02-)xSy;O z8}N}DBa*ihR`mD^RTcI|mm5zz1hdbj@Qbf{AZ>7w%ak!$=xM#uH$R3Ucwa@}k^Vz@ zh77hlbc^b2;-=tSogn)rR@XSlmyKL0nq!B>U-d8wQ;-tyafi!Xki=3^l;fDLK5IdP zL4gLUfS$fJ+sU!B-5`@n`~7M9QQqSpl-G#bres|InJgc1ZKX&n1#ZaOms4iaicrzS zaDX)-)9?E#gcrtOd6+$}modlGl&PWC<3hajGGXCwlV~DUAw8Q^B_gB&K8j3> zLFspb*nh+pwpi2xh3HSG&;z7Edcs`B%jQeM29>NvL46QoB~%icb;t1pvo9{wp9%@I ztlM0hUA=e-dJtaovvG&sAUaQ*SCJ__(J+rQD0nfM=~n(YC3wX=6)sN_fp+pX=Wr9r zR9A?(CZ0+x4pq&Oqb7Tv7U02;c2nAiIWHt&z2k?b2G`<0RIVUkG2vOc+2&hG{As`9 zGb!7WEZc!y5~-mf%$fV&JyknucrCmKy84rR1jF z1Vf4v0fz1kfBCc+BDi6S2IB{^G-E(<(;yAED!xh-jAG8tWeU@{n$+$M`hRo)1-#d( zTS+1Kl_=KNl&dZ>M39AdBczD#*W=cy5r&ozVeX0hB8}JWzi}ZPFI3-h{ zt3D$p0@NHTYRTN)6-df)icJ5_D5BlMbip#kB0ApoNtTyswfOUiTc3^1N@`b=8-aH| zF%D$k&=1Sh>cCN;!L1Y`OIBuB9h+~@tfnW^cPqE*b-QK^o`tKsNiH@Ht!Qsy3np|3KmZwqU{AhVLm z)dZ|jcmWE+ZbKohPw2@Xugn-#Z1J3+wn^PFoV%V8nW7bses92PCiw^8dS%?{%|iR? z!RdQR@_)8R_}9~&zyJG6zOcpeq=pq_UR0na?64xcL5I)EOCx=mYF4sq)F+%) zh6lNGtkI_&uM{(ZEzU^Y$11SUDtWTpu=5 zP%@CNeRB+ZOz`pmWiM8LG{@Pk3<_QyH{kR7SdAUsd4N@f5=0(3R{3cJ7^Gc`{(;ii~NJy z4{9Q{5^6?GB4ooVCn&d}(4c6VrXhuAAhL_9-F#~#p@xx28B(WUCvN_XY5z2u*20k? z`TM<4u0EECbhj69_qgd~nGkMZ^UeE)6Hdeg!z4k3&EdRfG7T(_0n_!FWWJCR?E$cyz?%K0|6*NQk%UN^ zIb6^NCr0d3!XXMtfd!Nx;8nHoJ5PzarnvR{$&X*A4@Ih)i-Vgow)3+yIMan=joEUp@MrelQ%731#(HkQ&% zm(C>VX!S~TRlQWe61!uO5ez|RMM=3wS^6K4Uzu0Wr4-hMIQB>xt8fNTYNYjO;h(^{W^DBs%p)p zK~$IIE|vk$2G`c%6!65e+Aft|BqV-#2nIfwJj)JCErxN9^f8=9$>TI4sH6Nr*(#~z zzQS?1_?*}RPmqNQ;eBV|mRfNC?v9Om{z`nyBe1Yqt9v=ybk86**@#N7LH6aTh(3+E z`p0f?u6Tb@sT>pUK-9CT5;l9=9{rEgcQHR%% z&?+9+4=;8Ta>><;;Aj>%gDvv~@W=n0;#uzmw&oa7CcYV1+g%22`U*&#HM}<$U3uDU z#s6_0H$WHxK@buZc|h?Xblo#dUsI>Xgrce#u;xZCi~E{;)A<|%n#85=YTh6Yhc#1E zuT1?uTbA(6c9U)4Ptqo`zdMhWW{k@p{q7D9=PXc-;8og!M*uzWac>k9lB;8H7A)UU zvATV2OoF~Ps-r4Ur(CBxN7&tF!AaWOUT+_qa`Z0c_r=^JeJRXF2&3Y27|C8FA<4<9 z^%ff<1?3H_x{E1=J)~l58*~GNN=T&J#0)shWHFXX z3(9}b{;{}ERsHSiTy6ggb=9nESCs;8lWA3wklvh}oE-1J7>p(!*fjZCR=H|FKWps6 z+`|3=cr+hnoN71z*wIzZZ>11m53?;v%zODi#GDGtdF|}a6n-^y`G);4?%@21(sDaj zY;TU2!PbxY_80e$xeccCX`qsD_HYRbykR-}^)~FzF@Eii9rI(zGi!3=lAd@vECD~P z%8Df%?;Htsv%@@YK5Z7*^&oS{Lr zjUJ!$u2Gz_Ssv-m966olRYN-hWJsoA;*Up(-_HG7l~c+I57olR&XVl0C27;yT1`JG zy%L^g$oU4Q#4vI%L4bt8@#aeWYH+unklGLbt*z#mDD;a`Iw>ljkiGg~~+SVQE`w6%!8n-TNuX6Cey2N$+1%Pq(}@ff>AyE1A)ZrG$=p6L4~BXqSVN)sou&456k2j`_-T>ha;T4>S$ z(=6l4aF{#J*>W@(JlHZ?dZ+*Nf)ZBh0|BpG_)yBb^e)m6FCGf!Ghj8%N;%RvMj0K) z32+B>@}d-<@A&%=6;cVwE)DRd;)|wx1_IZ4G_}v8MybIN8XAEGjZf{#UufK`_>XTd zzT%o+k{!-jJIroafhJPb2qqo5W}ALUH~s^#7@R%5E-15yg;x0$bN-$qd=!0NNMT8j zi*X4k7oRkHHK8TrStKk!YCjeX)&^ssD1^N5PT?_z(8+N*WQ6700nrBM`Vc2S;NvxP zT3&EN=%VrJ*e$1_Mj-1rWgMMqzb6J~lwl{bTMKed8uRXnQcF}=d^iNErs7=Er*X{E z+53*!2x4^;KDNGv>Y9WI9L5W%)!^iEIG4SK>1ya~Rm_XSP*4>VHR?a!4FB%~{;vf7 zR|5Yr5)euY6uteHh3uWuCfH869(0&0SaI@muOYFwmgKk$+5uB&_ed%ZU$Rk4X5oD$ zZ+64)PFf04{a#rwWrYs)KV;MaBwOBBr@!f8s^G{7V{^n!|hdNOVu-bmMxUIGN@Zo#Ll*H<>;Szr2-K47TYHe$rn-`6Qzc#_;Tem z3)4`kErPL!v5r1B58P64(%%N2OK>2<~1iP&HC^G7H#Y4v3B*exI7|S&ws`rBu-)&)fLGB ziH63_1a%@xqCn!mx_>?>5!H2y-{T+`9Xpj%5EXM?eZZUvttI`^@xQ)%BdeSUA- zeIV+`M=r{!8D>f)(3`S#z^(TBq2CXkc?qGUSnhc%Z@AHxUVSnrPAm9g3!&Vm!)^zs z^RTbT3B*LRCl*!VbVgMk-h|s{FZlxyDtJX&1OVd6`{Nf>0~}3B0ma6C2s*mqxpaS3 zSr$h7-bA7r+)Zo*oW3>ig{C|XB}L=W8CmnY(i^gHbT&2_O3s>9b@m+#v9|{vr*@oJ zZ{%$-4}YtRW`bY13z3ipHr_LrZf!kxhky5-)9j463B;R1x5wne#kH=crlw*m&rb>J zhd;YThRp2q%^bb@@au1@aPCgkOk>knfXl3@3-_S3n}zHgUjI7`;z;tN7od4I8+9Wg zbrDiA+=;yw2#gXGUp0?=x+br_tb1Q6ha9cX|5_~0 zwhp5I33!Jozdre1FW=xY%~2kF@2bZ?f;jsFsCoEnui%izDwv1DByFP{0#7R6alf7O znYo;(@PV*d7RaMT;!WMU?}YkU1WXvqhvtvJAZ6XKn@>@xw$N=0M>4rv8F=<{MX--(c-Qi-7QvF0}DXWguz#LD>n)4Jv>Rf-)^Qw6b4ry7l3 zkl6x08nA-MbhmT=mcE_VJCOIDZAN87+URboVrj{rk@y7 z(4J10+#i6|Xxc`A{z>FNGMM~|h-;dse{xC;4i%S(C3Ps1+@9y{qCLGWFE=2(-a0;;9qFu%jzYp!yE4S2V=#Z7iG{ zHRh)MGK^6U8(%@gl=VRi0a|g=0)hwfGK?MW*J$B>mbAbQcJni~yZmHToO8s@=AaBy z>;mAcy{cmkH}Bt=m{dQFu8s4vPBH4mM-3a?oGhONa?4h3Sq*7knoL}8!*}X8IPotr3tI3MDmRx1( zAa(EcPalrKdx5G_hgvikVJ%+eI}1B6)hg9_Q4prRbukyd{qW(bp)XlA?v$o-DLtU_ zFx!zC95I=E_p3kVd)(K0);G|JyncQZTt@|=zU+4v7w$)ys{|W$Ib@InFFi{LG$LW>V#H>||wn<0HkXfzxK*~sq zgh}cZCEv?cg{SHhG5w_!7Skd>9>qu7Tw62hu9+5o_U1f1#|~d;QhWC*KRrK2&qw#F z=6$Q#82?BM9lTUa;7`V;x5Y$8;^g&ULqT~I{)TS*%#Wbbubg6oC(h)=Wk^eg`x(xvQr+oQh_Gm+!Xz_`wzyAHwZ;l2rv9N9m_U?ov_OT>+P^t>g?cxWy z9a(AS9NCkmDO?+fIeaJ1{mu%&JoF6{eRuiF|M8V$fB8x%@W1=Y2YDeEC-OzdLi1U^ z;fj%c8aXpgDQH6qXx3IzHI1=KpR6F%ftZ3?lh5$v*-wy7 zIP@`L_xDibmxKc(l7HXx;@DcRRM?ph)|3D17pZ`+Zq5aCuxbQ0l*Bha%1XNBcL9G3 z?SUgN|T`rQ zah)93XL}OtbLZ6C0dv{!=EaMl*sH3At zyZ*HvalSK|kKOr1TANtJ$qoIBl#7tzU(^HoGO)qGH#iKaWfaW0@^9Ojtq`4 zLW4wdN1o=3NWuu#DSwBW@e_n`u9Uh>TpG<0eU zOyQyEiD|f97XOz#LRSxddY!{8F~#484Iz=sZuc?Lxx0tntLjnt+y?LTOL(6)a)aGK zj`dKm1Xwl&#wk_!{oUDL^0@Faz|-+<>u8$Vo@Kn-_&3>7UBa~DyrCekrE?vW`w{Qc zPmH!ax^h!E)*~nd9ET;2%)d151z$a3StJcpqRvyjWafF#-=dEc7LT*Gp`MxXiUq~J zJEGbC>Iy!JD%)~><_`Mv?iU_mOvrEj+!Om#37i zk$NI!AKLc2qj*79U~LuqFpQ%u0AT;L&7@FnQ1Ruljc)ubHpjW> zvRtS!&Mc;Rt_>cDp5s{J)!=-{0%byQ<^p@ae7o7X>oa#=Q*hbJ`@;ec8W z&{aR01(^t-8$~^FDuFl^TMEv2M+d^pA+SBs>r&MjO;26Q??kJ5p*%+Gw^<*+#GLjZ$ z8^mj##o=iJGhq|Z4g6^Qyl7hmOGTX+6WRRfYYP?H^YFEmvN+)NV zqoLO*xC=EH#1()6`O+X1Vv5!@;Yl5K#%$diSV^PmOUU3 z;I@RKgdLSHP#_=jh%NCgh(O&Lp-SFu=Y#y zJ>VReIrji01RNSdj1;BcD0+t|1|Fmq;rwGgAQLJ1MLPVwbK=!WnLy*B9B_0J2IFq` zxqdV5)@|<`iMN*H-4b6q4rNOXVjV4MOINIgDrK4S^|@qhdno)E*OWDS!4jO5PVAIi zXcF%3v$wzvbxXonfpXzOhU1TAZ%_X>EVZ!e@n%lBCAWbwGHjS3ubM(^hwX`fBOxy0 z%fQ^!*5(;Bb)T8XXu|R^<`V@4OG>3s_Z|L$Wd!%g&f5&wW$?<;1+`@4o+O6pWl*I@ z`0kx_!di0Sqvq>&z}5yYGbuWq9HV>P3bnuamT~5|7XBDw_0T`fb_oQ$pk(F-{(J4I ze8jl1pqT(PJ|2d-eRJ_|C&Fl09Nj5uwl=fV0dT zDfkRkT^guF3VP6bU@Fp@^LHl&Bs8r$t``%3Y0{%KIv?$-YLBtZ=TGEi0u`#hTPB+z z({%iFWk7Cn!c|Pd*TKZ(+nVq1-hLc61;Hj;V(lHqzC$TSRV49?VPPP-(b=|9yra|b z;i|wvrQ!F}3Z7|iarox4L2Yc0Ye}qDtuxrG>d+qL8CCuOJzMnys0jejW186`GI@Wa zPqQ-I<~?0H8%ygS={XctTyp=VjpG@fl?FYvPp&><3+gjV1f!;qNZ_5`;N!pCCr}D- zJ3z?U_08|n%%ujkA6vri!VD7yxGwquZektVGOy-Gd7fDs8fG|Z`<--jToOM@eHocy z`1fxx=uby{KMc_wRkEy6U}#<#^%87!!Kh~G;Yt#Yet~^Q6S~Z2@J$5&{5&8f_;put z_^$SER@B1m)~c1(2yyV!^03GYq3_#G=5f7H&#UiZFGac~O{XHNQ|!x{EM8Ke4o;9X z-t^mPaT0l z?}Ya7Si+*ze(aK`t!c7aUkxWwkN~6`1`Xl2<{k^sGV~Y5YVci4>DAy`T;?*?g~2HSD&Inx`(FEy_)a&2SuGt6tli+?rALH@oKjG%tz9OJ+JsuG9j&1lI4P3^cM< zM*MpHePZqDA#NCv=lL7#I(|MB4&FYA%URhv8e3XfT8InqXy@$rxixa(j+4H#(?geH zO%76Nbw!}*i<$bJZ#(e<2M^HK$~8eW%pLk7waK14S%N%_A795Gv7QE*(HHs%SNR*_ zcY%TVv!Amr=d9?e1yQD~R-C|58m0y+IS{6Qus1&-hMjwgLl)#4UV7Vi4KpW4W0zYc zfu+GkE5gqG1cQRAPFCE${`hZ$CVav1G-h*e6f4+FXbhO`H1^WH zFUAwSSE>tYeg4z^alhV}fe>DMqq}PkE+JuBH}i%={>fz@SKW)e&%HN_9>sh0I!h@T zD$V3d8CT5~K{dO5oy(z25?^(NZzC#3HW%f(Qw({e!1d>mljQLnb~D zj1P2OLf3?bwszso!s|9UHbnLO@1=*#;8>aZYS?Vuual0(4)}-5?u~!t2v`2fER=Hf z&ZU?C74*ISJF{>k0UU(>&Jq503KY}G)b+Qjvi+4=T#kM6?>D_AkaogA?r(oqSrK5p zrZs}Da?fDNjBu&|A+L4Lf<*`%3v_un+qhg1lYg5^re>z57U05pM&UY(k7csqxt`F8 zP<@=XH>(<5*v706*(AG-WJQb0!!QLw*(0@FZ$Cv;efs`B4MxE61bTC$FHsyv$yN9B ze*m9uh8*;V^kkg{iyr9RdL2)xe~BI@{sHv0{`ptN@jpL*z6$^hJq&aDWm_FQzVZjq z6`ppzb0aqJW=h{^W5}W2$LH)_o{I(@E+n`mm*TB zw$1nDy?HxRD~CI5;j(9y7%7p9yR@T}mQwlmq6A24;$JVgD9Z1{t;)3DP=Jn-vbrPs zg2?EV#OKXlaeP}dG@t)(ug7gWOnApBS7%Rnv-azjLIv;hEYgDwS~TK$D07konNUeQ zuNBkX&xJn<-f%s%ZFt-NJ@tH*ha&G#wN=hwp}|5rG?7~68#ao~itj6Jxf)Cj#VJyT z3ov*t`d(FjTj46JYEZXeNNdzH&dkHCi_|$ybIFR+pRGRu0Eiiw;+q<6tCyNjHzk!g zE(VZwf0YgxkJ0`((Y~$m1i@DJTwsONeL+;7<76@B7RW2? z=oExV!??tM#k5(M_1%5DCS_JsU{D2Yr^A|KL%$EeGzoD^-{NqX>dyKCjWJ2?PI-G3 z_)Mb6FDZ@lk$Y5|A}gZiM<+RpeZ~x>9}S~|1{MOp4eiul%AvgOy`(=Lw*ahtbuRLE#-;k{qz}_S2gzbHcwO)4r zewbEUc07b2wbpSsED?S}t-sl&RFoR* z$*^K&<{%qL(c|X zDaaD~z8g3ZpL6~8;SaOYAVm7oflzVq6Q@w`Q!j(PvC*Tm!PCp#F2C?*kZCc^e1$)N z6e3PDrAN(Z3hjZo5hMg*^Z;u`cJv`L`hw4WQa`SVZQ6|9`pm}{ChB|&ThipTlUIj( zTrqMxWvhE)J()`^JGk|vNHZboYC=sSJ&Bh16{?aY7Q~Gc3Ztzzf;FDgw*;QKbg4~a8M*)xoztD<220?p zVXCU4jK{E{nU0i3qxL9ew+1#OXMuE;D7kBSNh#6gnxjh^_gmvp6tBkha zUMd$!x;VBMga%$h)Z69;y`5zggwig6ymodyDX3od>R&J~9VVXu{7yc?4P1MSJ)Z4E zU|cNhC(|wqp$u%v=6>PTi6(h5o!oE{t;oLk$oBVuyTzTOoGyLQ;`9zIB=3{1-H5`f zE7l^#&d)wBOD<(x|CJHX^VVRrm%kQjkhn+Tvz>is?dww=I2JrI{*ZOSxgu|+o1L7x zrN0ZGv$|(ixkU9TT+LthZj3HLonuazsE~xd2BwUK1bE;nWF9JJ2fpYkbaqCF(T+o2 zfYDE!%7>*5@KlL;QlBSFF*xnAComXyaduOlvh1j`O( zR%Od;YGts9x}VyXyY}9&MZ8e2?3xbl%9TEm6`wuiPvls--{zQ7$tA8Nb3|A67)vRr zFd<)c(5zjkCu$J`5Dq%+o7VN^vzHotRi$f&MKHTo3A+w>^Gg=(-u*M346(A8R8(4tvu6`hOS&Rxe!5hU%5cEB>S-1(k(Oz#rlim2 z4=4*!@SE2A>B1&D*;J;kCW?OHl@|&0}bThv{KaBhX@ZPMM5DR{GDfB>TZ(>L~9(L&Q2p&yk z=qzKLYe2R*AL04Uo7Hgesk`!ckeh{)K)!AtSP!@1Bw5%gFUP-5Ff4!D91wU|e|m+} zW=+e~z#@9Jj5z^*f)cwF@qq(seajE6{;O#*ooq0MnOEy*0)1)$ZCN zFZEYDgPyjUI%`$Gp2n<2s3saBb&*sk*w9?%H@F$AIVz!v>IT=ZWKuGZtO8xsG~*_4 zdD~UnPMs)%Ds^(n^aS68hkvoCU7ASu7#6N{t0ueRxrt`FghL5P;c1x4`J}}FsrAm6 zL)ky>A)2vi!Jhdum9wiJdhfm(RncmU`OTw7HMqp*oS=HBS(2@ay);eD`i(}(Z`tWe z1#+>R7pjh#nfnV33j|#8Kf96{n6>H=!XOZyRYTz>QO$1ib}#N$8)HKWko0J*ddkR> zFE#tIUVekzxSzlbQH~%^pw%FPp(uyZ1O|f(0>LL2eB<|BnS*8+=fe9BJbZk+(<+y8 zR>~H-DVS~>ADl`)7a2NsY~>6xCq%HqV75IcI1YYE{yK4mtKD(G?yCR*b_*>q!sQub z?`6ib_hJl5t}LOjS=APNzhLdz1lm~39nRugCMIxS(t&l_jF(a!!huY+1&yzBe_eH} z)LA_gKh2$4c9}SYC!N~AC#fwP^F%3RoEIon$&yw{Nm$pOb3=+jn)k zi);d=9B1lhBiDQ1o81-|V2!L2z}2^SlT8!g+?YI4qPPU7g3N66Pk9-@&8CBaGkPKR z^=sf04>S7f!YgfA8~A5`058GDGuWSndJMV0HV&+scTCLA#P1D0>naHSHWmB_&~N8@ z&=G3*{6gaVxbI@3_Cp~f`|MqxSZ8>{s$UOreUwb4pgg(|SBcn%=ISc4U&tHxm?f6_ zC+h$sGh@#!m`aFHc)6I+r+E+DFz#o{eTSA)y?&N6NTb^IcOM6Vt;(83&Jz z1UH&HsUVX!3CHs@@-p!(V4c)#o_6VsUO*9jEAzNYbeQ)`NH|z`u8r%}<+0%Zi_5Ic z7vaZX(_F2=rE;jZ@R`9NEu3Af#vF{!r8M*E_&l(-xMui7; zR{5R9vq!?`3$J;b;_Olx$cBug)0Xtq=c+2-!3b5)-K=VF>~JA8XsC|VP8 zM*o_srDOAQpoIUMW6FN>)U(@TloZ4F?jOKr&L03q@#Ms6<30NY_A^PVZN5K%tmw0U z#asW=^SM_K0Ov|vuMU6ibUtku{sTz9Ax)n+1w3F9&Z_bM1K7&8|12=vR=4436W@@> z-ZJQx+Fu|FT=uaE=E;9O!=)Wx=H}v9jk8&DbK9Wmjp)UVhatv5pG1pWveu16(F%dxhBGV#!O$=sZhIVs;fKPDLI4Y0-2LZ#x zewk7lZ#x!A%Nea$!^ST88D|p)ph_TT(I~jz@MbfV*WPpMf+N|rS4c_=Ub0;lhpa~z9 z@GU$r*xf!fV<_+rksg@0UFaCo zjRzqD89hX<_LSOV^b3se6)SX6z2GR}FnI<|YT6$w6w(i)WJVhhC2K?zVy^51*gQn$ zJ|SMaC3}d^Gq+Lx$*93F->{I>6+e+<$#Vg@!Wxw7;%+HZMb1;QP>aJS`YfohbrvjRvknI#vLFj|@o1{<%Sh;R@EqAih<#0WW=WeQSN5*`*VpQXCg z>!>xZ41H{;oz94NOghxNrwdL(zsHa0=VG%Q2Z&P~f?zuPP-9aTWx4tEK#K+=xRCT{ zEvP#{;8}MaUlRYY)V@-FdtR%bO0bEBx0LEGA-Nn^X;SS7Dv52E_$s*Gcz{a&sCvk1 z;+Ep+@{d0NEx>w{z&mYdMJ2UmyheUU(hRh8e_X}h;Nw&6p_Phn~6#3~n# zU*T7?W;n^wEVHt`5#S0B`Vlsyp1Y6_PlYpaSf_ADsNj1XUW(U3UyTBjt?xM7fP87| zKp9)xypQOu6A5(Eepb)A?_i-nT}r-b9-$FRQ~&jc8m>L2_9HjVCk-wu2FWKSbH_Aw0`^7& z^aTwp&i`7Lvd=sLAs?Jp_7ql{N{`vK5OaC{ss6(Lss6INi~sUI_xT}ktU)ut{5h)6 zG`p5B|2`fc@w8?UG8&9GK?MS_F#~bZ;lSJWRRT`Rw5atmZSI#BCW{evr@wa3?Uz11 zeQf_5_Wu{UG?p4j%k9U zxT!%N=I$HDTd}v`rozEqY-v#vI7>ND+())j9ReN{FLliXCrZ#AWcEy` zLTLk$m+D@HKDPA@BV4M_hZenH0UF1adVdaG`r!EJGKQ9G@GUL)Ve;+iu7n7x98TyD z98ZwoGT0!gq<>awdpdB`#x+BQL}(4sucTUW*CT_>MQpkxuerh8WBO8E9}7AMTY+e` zVM@aME8N&9TLSlegG&Z4T7A+4Emdq+5`;dL{XZkjCg2J z8&>LNqDQx6ZZY0Ii8ck}TGzIIBv1BXhE!h8U!liTmVw^3*1^dTres432d9EZZ}oOh zwH85BskF5gB|^xngQ60zJ>j?&d6sBc>YSGOs~=y<(`0ZxAiu}nU^=RL`A522Aa~5s zKQFK13m#sFpE0U`0KpUt+Wk<{m5HibJD;;skKIeD<{K|}j9p8wqm&$zuZ=vU(jm0} zKkU6{R8wmkHi%tOK#(FJp!AM(L^AQ zngAl8^xoTvAJD>C5D z!+E?gdTP(W8>pfLa!CQ)j67ZMdL;-kg+zTb07@YSKlowf80jb|Bzfko(4(fL2o@8| z0xdNGITbG2-vTN3bQ`CwiwaqRM{_`+g(Cr>sP#!Xn@zo6j*7VQRO`fg^t}3V|0wlM z{AQ1Z=<9-!SLS=Ei=0yNVGl`~rW}BDL)(D2AmZAi&K6JhAr7kdk=B+vWdz zYSW~i7q3p#yk8x^e6Vf`s`pqZx(c;j(tS3dR>vH>T3TA?Z#4YN+{9aeZy22p3UQ#h zuS<|~5HZ=d5S;3%>y={pkb5>du}5jMmmWQ+1~NCXHGE;#PuGah^|KAzOHXuG#=veH zGAnL-55p4c5cMzvfhy(%nXX(x){QE3?vA!)3JbkmTO?9Lv$LYB%R(b0K)(q_ZXJV> zrH@|_NMW`llgsomz8Y%z&!eadh2ywCiacfX4iUJ`18rp+*1X;RV3Ae#m!UuUg~|%`!9-@wFENv zA?Bd|(g)>yx2rt7I3}Ir;d0)LNA-wJc_3$xnIP{BgtK)_3!FwNMGiX@a{zm-Gb-;U z9z>o@-_O`oPS_`h?|1}ddxwM@InJ}n6FU!hs;Sk+ISumh*RMSb-B&p6oe#|=+w4FF zCe-X5zET|g6oedZr?0C!&PbaXr^HFxwKHbu`%C%n(~V+~^3ivz0a`Z0F!-}Y;TW8v z4ZIxPi@T+$)l@b$!v$}aRrx`)+)GvY7HaYVw~A6nFUT9pMVPBPL1)#Z)zcn{%8xk8 z(oeO2@2NV&nBTb z+WB9;bjv!s$X!)xkWh-&|MKO(r}IXJ#d;7mW|Ye`H$~p z$+SQ5v|o3Q?Dwx7Z2m>jR`IAF#r|+0+)dxg^)HGuq06lAS~ve$oX}tSY4aCFN6ZB| zI$?J^_wOO#2PwnkXbPL_5y*ahb7dZ))F4y7jn`+P zr_~D>_;3Sk<=KNEfo!(K!{c;Kl);Ir}3n;T6Bp(TRj~-sFQM#u_ zclQP(DdFe(K(>c-;kH_C_}3QUgantfx&)z#!bjkq{O1izRXFJ$zm|E+WNMJ0-S0*s z#5lfS486~zmiGrCp?F7MSA8oLPSov^o(hzGGbP=?7Ts4cQCBSPq^FI^6~!hq98%{D zm5y~q-3Hgr3IeiK{FWbn2x27rK$_hMZf~Hwb)tPgII9fULWVFI(vQe#p0F+FhMP(4{B@-nLSiI(qx% z$fJg>gbJ!nI3F*of1&%U=@;fSJjK+|RAdw_18~GtK`>PX&1#P*f&NK7TD|DLu5XXI z{LV%@)e_$>Hntkd`W&6L`)p-pgWBu6So{?j>a=~5~=(tS-XI_Ff4=XEKJ zHZ|!+fKM$JC?XUoTgkUL~A@hy0O$i7(pxx=xAf;RKa4Hx~j@ytS3 z4tGg5UF?J%$@5z@{J{xMnx3%F%yiY^tJnI^<_~07;JJ}6W*Uo>7d~MqXJ*R1V#p}4 z(5TRWGQD5oG9m2quubi5=nBaUj;gO#ymb}8y_2tRZ{|xACJAYMecHRd@U?4ebDxB7 znIJRG_@58|ZaI6D?lGoYT(+)1KMfwQoSbFQEM~gam;slzPccednNB0ywIQd7sy~ED zG6~p3yjad~2*`2A-+9Zzshs8y$eDk!-_l%m`g(|g+N5u?Z;^mH#2}~|UIy|c-^Ouh zh>0>C5+Q|1vR09fn$Ylfn^k(ZXq z9CjYI-@M1>+B{G+Rg>io4>n}Vy)$G$?sF?4Y>m6c+)^VZHP-}Sl0Ok@x;yp` z#P=Jk(&_3=S}E@43@-*BK96098YxgrMaB2f!OUWyv53Dr5im(ka>m)E6U|t{>ovbt z#Sy{mZZ;0F{duQ*i{QA6i-$ok{6G2#du534Zs@P>5#y)IRg-ADk3g=1R0{hq`Cy z@*>+6p@RjZJVE9WI=j4MHJBT1AZ%Z^Mg47~OTVkG+kHDifoW4D@OYO`JEmWSJ~Xk@ zc5!>}9|w7kjE@kq-u#Kr`!l6F55}8edgCh@XX@~LF9)&}fm~lhD7QbX4zoj}_~AD} z1!M5<*8%^iGOt&~`6NphxuP&!OyrFKy7xXGJAvtiAH$GSAs=5EQ4~xL{oY^5hJOYLn5bf}7X{ZEphj2^%+2hoG!h^;7< z;V_*2>Z{RKuqlTd`vj30***rJnbh(kIL-D7vrEt5T(#F2GEJJsXQlZEo zX(H~q8d5F-IqzYopj*Fkb1>PxTL2(un<5m(HSelECpl8~8E@gfQq)6ee*HkZX?l;C z9y{HVUM&FwO77ap)Y;Y+00s>E%T2k<@&OoLEiJnW%fq-oCq*N&Q|G;BcEY|2(mzon z7HP;O6Z@BAI_>^uQY#-gN}w)N`~_@A=)*l9peuB&hdzKV?$+w^3O9~Y0%{+AWcGYn z(~G3+GB9BJrP9avduol@A#sP5?2?6mCvRKY9i}_AxlcJ`EC5i$&@FonG*+A>rUxV| z@kDl&h~^2MH5LDQgUwv2CP&oA*vAIoQI~w=FXS4q9gU0d^tNO{oXDc=!xbI5FafIK zAMipm6@U&cInqXydwlGEE93c3+>wFX=CRUfFuVTuOr$AZv1Gw{`-x$QYV1=-XH3<+*?xXuZu_Rn|$bEzqP zowSdxVej8d`fZfT#@G(vHe3BE$eQ(dJ*ON z+#jH6@8e(8Cu9C*7<13I&Jda2&u@y*I-cVVrcK~G*{+G&!FF;4-&GR6eEp~~=(=;v z`bx+dxzgU>uvH!jPWpWaK&)EpJt>`Do`5$phcK3S$V+&FzVeL zY_UZ`Gf!;v%ET?sSR{{+sDpp{UNoggoSN4KBX#MuWHhUFyIzPnn8`0C*x1TrmVGVt zYK&HkFieBP^(8Dn$RP;1?YULT^}pimF#;OZ8)|BZvN+I@j?9GHQ_>HpS>AA^kN8Sd z>unC>o~sS?yd;6wuY>t~an%*ROoc>u=2aIB!fT=bW&|o zIt2v>qSaj=4iqj%ER}vQ7|E$BEEve<5w2vbNfU&QRdeTZTaHL_KIACz)CmsydS~*G zt3ipOi^Jew+bc_nuqpPF4y2BR9VvUOvXS3YsNvuG=I?Vaz8)1!Rcsc8N;B#x|8X)z`3l`*3u6OG z!c$t8;m18dZG`iZCOGXG&%k93xa3^m$jk&Z*TEPMe{Q^^KBWHcl+oXI2$;g2xE5}a zz^jr-iU{O^#ji{Ogu8F@F_xu>ZR*eZ+##QLuOmH^C3Tk|Dw1vbC_7DL{&;f;Lc4$hIS{s9tx<_itS3x!i5I%t&3_U zglwo?zDbd(c#s@Gljvi%*%EQQ^>#{UOHp~$t>uK{&zFZ4w?;OdWWOBWFlsqn3eLXK zjGn!+z-D~9nR^j%`S$I9O;jQ;{qw80iCe>K{iz2fZHJlb*ppCht7Bd@u57ge(C#Eg zIa)X4PO05W>AZOavRbdm9K#J;UU^*op8l}!=%Ek8p)l?*3Pzrn|C-QTir%8QR2PT7 zpq!Qt{3=58eduZ^?dHGuQ52qm{?m2RA-SD9_efsL(sH^PZfeMW3Dr1- zshCFNFpMCV*sG-b-xzcYA*BSgv9sh%nuOV^zadUEo#fZfi~~Uu8j)mjq64X~V^jKB zWC84kjw#I*gH@doTgwA!=(~3H-#Xvb&AVJXK}E{UEK$63{9UmP^YL1?Q|nvpZVqq@ zqo=+Z`Le6vpN8yl?3JJ<+%*=@yVXD|=O3vN3iu?t6YV&d^LR zlj%Y8EciX3aryA7P-+GvG_hUxsi9zKK}O)!06C=MH&JxLM8UAFLU5GqLo24*UFN*B z*V8GKNar9tOSWo)4CIsAO$?=ccPhf6jdl!Q{9(N(_WRb>lFOy~jgPW?wfk?274*jx z>O7_u(FT*>^>`PAxkzeX6i>-oQwRB`EhyQQda8j5yp2tb*Xp)7lGvcHbDB<*j`DimEZlh3 zXk?ip(qK4+yhZ7BH1J3bEf@odN$%isoCX?_2DJ&r_dJV8cb%6=O zm|;-U2B!k1u?pZB$PYkeUpr9$a+E6LIvW4ErabgEVC#{E3FG|)(M-j$Csf~q%D#`` z`ij(Eb3mbh8Z0+P2G571#&%n1aSKaqNrQiGb;-8x*B~1M|M(IFV~Hr^Vn9xvA|#Xl zt^sw{j*T?IVQ@(9w3nO7T9%=+2J)}>LP~S)2eC;Gj;j;amd4`0TCX>_cE8rqjk`Ad zn&bwEqeZrP5Ky_}`X9eLL+%U$O)boK+ojCmN}&yi`6#RWwi8J+vX=X>@)Vs%1vg*Y1*l4mIPSN zcYq^s);zRVWucx3a%;#1tnkq8q39w93i1Nid5<_AuGGy2((L|Z)Y{1LdVeHHi=Yu6 zzi_;B{!M(BTurZ)UNRHbKG&wFB+ zts1T%+!N^me5I{wnW(cntn1A`GTN!YaYaJ~r6ANK;|y{GjdQPDc_HKSR#B%{!#+d{ z{~+so1NrB<`yjKVPaf!pN8CKmH1ZYmB>=QO<`WdXt+A~Str&C$9Pdx%`x`4nq0rS?GN8FQ*wSa(&=;`%jvgbjK?VavcTd z%YIol!Oq;g$@{~ewUGs@0s4WVOybyOOp8&ZT%Vlq>a8HN>GaK1i@_0!m;&i zL1={>m2x{y@X$xasP%cVnIu2bVG-w%qa?zhf|%EmoWsk5Kd#QlmkTJIUb8*@#}UxB z@mc6pXQdo2ES|inV>ZNTHhPv}fGJ?hBGsb13p5K(Ja3Ip+c`TDb}%T+O-Q5uln!vk zH20G8Ly9Y0K|*(;0;h>nc25$uBHQaMNr;|Df5l0)t(tIAyKUWz87;GF%jQvUdAPH$CXE!9z2`b+FW|V%nN!096 zbpm$WEQEliM2`v7IgAz=Sje)c@vPXsX7g(&Coq8^Nc*4>N%7H$h!Iilw0GXq2^Bgj zHPe7o!A|s`6}GCoHVPC$h5fyvZ*PwP*TOg@6`#x}v;Vwthjg;nbK`#`fS(f$df!m@hHqNeGMG0!Km4hVk0})3$#(-kLxPA0-Xzrzdi*Xev%ex z#~w=yXp;KK*T9slS;^TwgHgsr&#d&Nl-~jd64w<pdtV>3nJMa zN!IDvs`C4gp+fv2J#cSIN{1duj?FQP#e)l{_kuYg`uPC)S(M1_B`_1jACcm2*Q0gJ zNsXFg>|$zf_)K-VGiN&GrnhtSa4BaKC_w;V?Qa@BUwRMAH5W+Djm!d7roAez%r2*L z{q{D(>7G^p%D9PK<)>gZJpVmwl|sE@H56~fS;qsL@MhXSbW^Nc(gGj}$wh92qV`CR z{-x*aa>DNe46;WqB;WfQGS?L)ZQR-y6oMc>4x=mTZ`zHsa?Qb1EWHu-7N6Ta)|oB@ z#o9qN#$|_(@E)$7hmO54PWy+U5q-Et;CHDY(~dbUNMv7HH*>nb zw#0^X?woWNQ-|{83^C}q3C7?v>_*uv^RY1&Baa=NG7B=hDb2iBn}u=De&scx-}z4v zBo$Jj@xJPU^wgCIeLHaPVbMyGG0ME?YhigQ{bH{ae;%$)l8w^Ul%fOwF!P>S<3^Praf?JZP^K3a%9{@JQHd%+w>B#r4l zEwhhPU{tE*H!&1!xBOi~qF>gLAZQLA*QVwHJlgA9;zQG}nrHLcs$D61Gf~B`FJw9K zjlQd?=yvVPU-$PNk@r%|)nH!xz+^E1Z^QDgG0FF?^L$n+!m^FNMJnW1=A^2*|0#j( zeNiUci=lGck&>}>;B-;DhQgDIew>#*r<15bY0bRgUNRiOLpMlHz)b~7svG_fSZehj zSSsZ+H`pcnLx0lknk6&KI@Uz6IL|Pz0a64s*ev}x>PHU0EY+~LB?{L}`6+g*fPUS3 zsd1hsB(b6L0jQNt}gsLfbB9( zL6SZDtM}NKKW#R$$>h}HWCI!J3pC@55+(+BpveN%HTKDz5c+H*wLC5`E&n%!i+ZA4 z?AxZ=%gHttcuL^PBrgg`rpF8D_?rvRVCv=@hJ%`%mD3HA*KB2?B{`~PBlx^a-0Ba+ z`%eDD@RWGVhRddXa^;Vl!h1cq<;$hN{cJ=6R3Jbw3PopBNO1{lDH-rS z!1V1as1!(J4QpU+j`C-aZ)K;)$V7*#6cDU-*tt;bW?zd4_@%}+NA5-iEBN6d=~>6QXiT~WYQKuI?*d=FJvj175|uXS;Y zyq3AYQD3Tb&>B`dpE#Z(wdCC`fvkP$x1D7$1G#gg2~A8kdEn{9#i07Hs$qPq8Sad0 zuBmBy`?RRX9LUq1SayE|467Dspn;&Gi6w;tJ{TNE^ijNaUAP2@k4EYzx!m` z9oE|6(w>4*fvP0lZ2b=>>Ev@AO!gahl90m2Mv}*r;pDJ9_VZr+jRy7AA`#)a+8ws) z#2-?7MJUrSsufUQyR~5c=O{Nk!C?V0T;r($l#t*O-M;tW$m%twS*%CB&~y0_(K;K9 zmHNIKUFrk*6sJpOXB%wrM1efW0>OYsWOL`Qcd4wG8|zFOyf2NW)dSN5Po;41YsXQ zoBm`ziMF=v7^ zfJ)1(-LX+vnul`ys(!ycT#~0Dp(f8yKmw5Nk0{G317#s7n=i@#3xyumWc&;WDVh!3 zQyO@u{PJGm%&^=09qQu1Lox$Nk>)L+EW#E+0j)-wh`yq&{o~q*C5U9KIQ3u z{-HTB8PlzH=actkw>k9F$2$6Lia`&|heWToC0WD>`ao?p>CTL&v^6bMSospeL_lB; zAQgA=%0dY6C&d-!pnuH|qmuqTUc7y=CH@!1JeeaQ5qx~qI`>@hy3(0r>u<9Ah!WG; zf57|yah*Mi&9%*V&Q)8s8b|G#7!@_xd8YTY7cy+yYpa1NZ@rrQ6In+0Dv^9({ZGp7H-S2flk!0$t||li zOO8d)Y+vldP7TMcWRgYn=b9d(q?4F)knp{IcXAxpjWuSarT}o$H0iaG?i_lRie9dI zg+?n*z2F5>U8ArTpPtN?AH*7;;mF0!O@fZMfp9=Y?R};47~{p38liNT7>N+k;uj^q zoa3o@QJ2Ex&$`77xMfB}auw3Y@Hhb=sDa$i|Hhz_t(g4m!~$}!65ZjaqZNN@A6D~= ztSy7+HEPGk#*&3D6wL$Ft&vI|(}o-Bu4MFYf>PvFt*c^_&m%plT5@+9c7(hf!3^-B zN@p~$m6n#9xo8q}={@TT6lFERN#2S>X?_ad@ z(PDH*?ISpVL%U z7{W$=QpVlZ1ACgIdw*S8@koe%?eEO@2wX<&HC(qKqz5Y36&d#%_vMST7j$!!wUOee z>N0qkcnD!hNn3&E5pG#LRKM{SIXc2~eBP;hw=Ea@wigZUE%el3&V(0RQ-zGY@WBFJYW znZeqW&3W|MdZLvJk747qD6WKIK(zwW(RS_ndkn`?-H1m+pCdQR<5@fg)RTu(++jOR zox>k*-q_5P?LGDdoqnxr)r=`8&c4mmT->C&P$wCPK&7B#s^7yPX!&E>&IEU@D0bF3 zoxlPFzz()oa~$a$DxiZM)IoIL?D^T+U$hlugL?y%rgb&g_VHT@n>HyYlsf7RXUf6` zdql)rwc_KmEZoW@msj+7dW9|sUrZMMqPVwlw9EHQB-8B~Gj$^-Pm+=0N^YK{BsX8# z<&>{)Zzq$zn^C9gX>VQ~4v)7qN&9#@J$*602l_2^5SsBJ$-PN4;s*gqgYFO+cX1oR9=3vk#rpnX zz;+-06v1y`MvuIfGpv0t$UO!lWGXd>;SE4NGJi`ElsORq$|#*Qi?I8oTBxwd$_BlE zaa;uTZsd`eD$sj!O*ehdNXKuv(!#WHCqp_N;-o_71fWef#P4SE9bsg{YJJ!o3_Uk; z=NE1}`HXRmm9pS*fbhNpHWOst?i|_vFco11rB87O0{sy+JIOH_T`%ggl&0Ol$l~9l zSO|B2zlpRiE5rR5JH56v!z50rP`b#*44*VskK3X>z^<+%@vgmo>lr^W{R8!w1QBSM zD<0DL60e7W{1Q2)WBQYGq900|PIi1)-=}aR^EuSxmiL>JWY&ldT(EC+gXFO+@b+En zp^PF=7m2g(5>?PDAXu9>?k@_-d<2g-SrW#rdK3#5 zGVIOrl4dc2a84vKw*>}Z98GD_=UKy-oLF_=q{%{?UsHdd2nIK_$*9*zP;G;1R*=9< zy!Jbf=zQsqxb)t^yqYSX6gdz#mJo(c4dImLaYSD5S?o)1=UP}28+C{J&*?g%Scc-P zib*2ixq%djc^U^?n8p6_DqLprF>H}+L0-Nb852@)p5X!-dAQM3C~p)8GZfr;`McO2 zCm!vs{<+|hT9IZFFR+_CP(-TNRSutRi@9!ZtH%2$wz0igAR~9slS*S0E3Q&(J>>$m zdo>ZG?W9-9`|9(EE3-Gwk&jiHI{~q(r#Se4^G4KRz#9Y9$BVj5WOycUoJBqC`OXZd zq7LQKyA*G-gQ#{w$eL|nJ7MEda|inI#+Mhf9uG9hj*tyBqNgY=m~v8Af}4`4RHsGy zH(l=Goniga#oQVF6uD$mIV~wMp?<0PA3UjMkTLQu6`IDIuk?yG!1|)j>g#cQ#+>K~ zra_6x1cik%#Q5j?sCcF>Kh+9%FM=QM(2n$q?03hL3qkYGA1=DynY6Ze&x$Qhifvw> zIcFCgX&Y&BEqX2cjalZ$Xg`F?f4;*=-n8R$ew#hl{)e(>IeV4|Mk7uNp2R*%!q>-a_Kw!INP(aSDmRBNKMj$4GmM%-Fq?ZQV^T; zuGo`+1+TsDmyiB~HjERZ2E3br@MQFUTqt zHcd*%-NNo~$}Bpz@mc6y0_1_pUle!OWBe8ZQIgGtFUg1ID3~1f8NR&N6%)8v)2_?9 zDV#{Qno$*z`;`#itrB~B_s)XQ_th3tY#)Aol&aZZNjwaZ6PJ5jg~Eu+W%OwMwkUu4hgc!UqUBe5nwOGmh*s~+%fn>Fev z^R;VOsY{a6%h*E>c4%$?q9EYEDsZ zXj;#_S+$x>pK66e!&q`mWg55!i#!;6TR2vRek@Zr9Y} zci++w7(qyyPePh`?56>z$R9(Z7u2JDFDLd0NQYrQy@{}YDVE(X&^%Lra6E8uJ*CF^ zOqoF;R}!PTv-6ooTHyz z26w3p|4?n=nN+tHh;Ivsj69dK5u(3owPZ3@J*gC(2(}|>PYTkyu?_KVk5ba3c&mU& zat1q2=s(M@{dZYL25bNm>em}_k9L$0gE`gcatnm1thFllS-q>m zPCciz9NxcInwjU8go=Ik`;+$1bJNU+T)lW0l|G*Vzx$&x^T?{$vx3ajI%d^y3qdGk z9wwEvqgM52ia+(VQcT;*Z&Z9t6E8?Fcuwb4QA`i3QLp5s4<|U8>DE^9JOcd9-uJvb zdjQ+!cFjP_vUI!W|Wz|^dp z{X&`^+k$SydgEC9#0wX#t0c$OcI| zllFD%8E>{3!L(A?C{{gxyd+(G2RO1l1;e$(8r zkp9YuG}?#Px(7b)G*Ti@D=r`aX0DmT-=g;Bk3pX>rnoz_Ap)3Tb!vVWxT0ycU+X%? z>%Rpg#ZIr2u@9yrfaJ=B_*Hw74(y90Ak+U&{pgE)WvSptx4_A*!MIvhk~pG+CSb4w zA2>NT?8&l}W;nop`s1!k$t7zm--W4Xu!M-xB_F5}Py&@ir|NwGsr{r98Fw2P*+n+! zWTx2s`Ja96?+=O7dA%dUlHhtCq3wPbL;kvQmf$vw&G}aWt9WPY+~?4Qe`y(Q9$ql( zSiI7C;93cvIbLZgQm0#}D z2`T7nwEtP1GRaMLRZ?}bI?(*iera!wIJMg;D{MBvK0OMg zvZlq(8DU3AF}zQ3*TY!YoB+@#KxFdIUIaL;qOKAja;DTB#TmmYpvgfX2j@|!H*X@k$ zYB1rqJ0Y6LK)V@E^U!^U9rn2%#vJLVE<8X8l-#-pa?_=f1_^ z7S-tq*Vp%641-U)@tSvhYAA+R6WZxAe$ze^8D&O*FbaD)eQ~fkp7n*0m#ly@hDO-w z2Pj8W-mRO8*ZDhqgP7V6ezSKX?Qw+LkTALgwZaORltvUYQlb|?_M4>(Sz5dZ7$>n_~U2v{@T8oW(a3Ak6AZA}ng<@TO35PSlUn7S8%VjaQ8w zPo3B183+HS%?0pjAyd3Dy%OClL0=~0y zcF^rQE?&idF`&nPQu{eC-vb<|f2CWi+WVFu!0>|RUIom%N${#4ElJFp=usP`WirXd zMcec?bF>Ck%bT@K1GAxJ)Jh97G-4rj>b5y`2;w5=sk;OJaKJmv;0fYn3V|VBt~N?w zz7*cC{QTlc_C@6g&6)I{zbLB0uXMg$c~%ir*-WNWXvCbznO}^_ACLT)pOUTfxl+*U z(Hj)U6wRYC?8_{<`&3?1#Ajq=5LsJGgm=W@Yt=&Fj%#5)GO-$QT5rT#Zmp(m5KYd^ zfR}8lYC!qS!=Ex_wI;s)$#Ngt{V^f6NM@vx^Nw7|j8sxzXG!P%x3qIT0nxgg?Wv2! zjAq~W>c^(36&PpSW6-HJRiTKKPFKCwD?{%njY)sLm)921jH!>l-=J#3p$f{adn_(D zy%~G-+;J+K`427cZOb&;aYiEbettO;J^tI9wlJ)`V`;pd58v)!z?jl3=>pZ|R)^#- z@g!oma0AU+21^D2(>!6`85shMTX?W6PlXUDG>=|>{eMVsrdoSPL9J7!V>t)Exoo{L z%A1p!v`a?7fc`i-=9aZiOAld5hMV&t24(d`TiD#S@t#$H&D_THtW$1Eokb#IzkTuof2-w<^h-#E~YiU+~rDM18|Cdmt6cAd;YN@d&7ZgJH!U?T)|jj#6Hu5&EvHq{}*GUN&`=c{04Ac z>5If{13@XAPDX5P3R!xTEVcVJ9kg=y-P=EIQMs9)%gGL)%tWAgwa$njQc?e#L>XObZodqcRA)4Zt6nXnmcSQ z1>VNHP@A8@TqO7@UM){a{Hoy|3a2t>_9f+K9 z7zA@ZixZ8Ct+15$Wvhlm;BPJ8Q>$T=^9^WOgk?abe~{rX0JNU=codHU3YG08zVt8R z^CdlI$&Qf%kk!L6kv^|{*0SHZ%2>I6!8y+rYg&RSupV@wl3%Jr*NjdEqiE9bZJm z@s>vz%oTl6f;s-KfD z%rMibozhCRuR);@00GH`p8S75Wlxpjb`QjVw$}Az?{MGw^=@ix$SlUG+_RMPd7r#v z6@!z(QZj>jiK!+P74LiTnnwc~%C(_V^Fd~DrGhSzjmG8(Lk$E{C0*fO@Qp0UX?62a z!6#933d4)}7GWI;M=>L#b0O%IZ@s7RDPTagp=eu3tDurGFIh=OkZx7TpvBZXsbP{g zAIyi~gp|Ist_yGr+XP$rK+CraE-zIK#JvXAbr|HD9@}Ugs;9XUif$$tHC{MDvAoEs zCco8ql6NVA`)Gxm1I(8UG_(agEpr;1brO>*gVZ1p!G^If>D`9OW@Y~Q!Ri*wGmCa!K~sGsM+D1NA`54%gh22bqSJ}P10Q&8mUc3sN?JgG3+(;q z`e&fkWp@Abc1)1`yUXPazw(J!#~gO!;Rc)QQsB2_qDK7-`|qb?O9`GVnX~WrP78b= zj}9rv`y9GVJkCFnhTJfGv!SP2`NFAHoVxxSNM#tbY^ITH>XYop@oygR7blkU5yteL z(l5nz{7x&K4T8l^BBF(sHFJH5Xuf5LtHKo0){55aE6wq%YnP8mSGk*P1+Q$=cnnh!^m*?q9HvRFxMB<=q?70*@bq7{28w9uINi_Okf1I?UE zE+Mp$!JYeYOZf|dF~K(ovt5zj=2DKRA8VeeG#p)^`QFjQy_)~@Hb!ItD%bHn-wc^L zZ4fuYFIAh{2_yvI)QA9}R^KUVn*_Ej3^00Y+%=H)ME{^Je^JYY5D+;@T}he#ZJ!_$ zS_@HVc>KwaUongJw@`U5za;R^$-TgQikDK|c(JG(@O5=(y0F3wO}8mux-!e9L(VX7 z@SL)Dfwo6SKbGc!R-9b;Gz1ie!dm2}9SRemFz?lV{ndd-R2 zR(5a?;Nb#%>smn;0RjinRT?R@No1Kbx2=Y1=0-|G7VT69s7hKW-N}ct9BoKM6J_?r zdG(A|bY(kY;H%18tQ+_ZDBH5jNI9#$AFyCF zr&=PLKM|eWC;!3Bc}`8koNNqD_6B{{6*TwLyYQ6Bqi%|dYTKOZw9o{R$m>xgj_88`Tvjn+(Zp2GE!MFdvESCyATV%z7AvYPfTe!PP>XOvmOlCyIb|QW7YqH zi~S0u(R5^{qSB>L3>ToM`B?;4NLZAqyq~I5uV$NOW@0Dx%TF=wt}Qy-Q$ifd)EUA7 zGb>xm_AomZk}`XY=`u*dN|}{qExZ6I*}w%Yo*fbpM{%9TmSg2Vtt~*lr6l*4m$RQODvrb9I$2ktTt#iN+Up2>j z$AsWy0u>?MJ~#4?6F{HxB&CYf(vWr8Qv1xbrLdn(Ojp&OJ|zFoXtI5wsiTL^hHGzs zgfZeYOGaiwj0<#MW?Gv&B*GheBTejb9U%xO#SRTyepMkCpE)~>9V;RYI_;b5*cWH@ zkWNnYGtoAmTqv-$67svYVVCOWrK+6hl(;p#=MUMQ!q#@NP7`o7Kn#Ua0*Sh4}z$ z@Jb4E?Md!1q}Qvc4lhx$Ue-r$1hdDJ?yubw3S@%|j3Pz{fhts2=R+H2vCje=vjK`m5hi*ck#bd%de0h|owh2BSEWjKwiYNYEDh}iJw#uO z^4fGkXhjfpMPm1KX*sALW;&7Q7~MX#VQ(QCmYUz;rX@y)m>*rmMz%fLPvfQDggx39 z{%D({ApvL82;}F2#3C#r3BiWf3tgxt4tjd(FtQKk1O0xbFZx%Dh~o^S;p%HGYvMtsQQZ=XM|z3S;LSJdQ9CvQvCZvh6ZNFs8r#(q0hfa{E*FK`1iQmG*YHG3tApc-Nx^;+qn(HkmXdvrW zcTE^`mCLlVnLShC-3XE(v&?SPpRjs_-!GnQUbWp%93^F{XJ-r9l-c7s#TWNWgNHsW z%M1;dCG5S;kI|%Gf~f(+m6EP8Xvs>!V!X^7*aB-Y^XggH4x$D8O9d!(Y!`M?U`ZlTM6UL<+R zsymZ~%3nIvzC81vWnU~3qbh3Hl!h_!E?3ZH?HA7_l37BWnI>4}pgS$m02H{~m zLMB2|`=v0G%#p%SF=vZsznfl5bv5q9Z54awpeolhrW$no(?k|%Gwe@2avsk|shk*V z?cNfUq7_a}hUktwC%*u1sKCn+l&eBl%>A7SBA-TmhZ|q{OGBWPD36RB}oq!M(a*X`ITk&Z{0#JY9$U z%}q#SR$$@e(SF?PXeY>J1vKM=B%}yf2rM5SdwH`pY_Y3gf02pVBpYXQLRc;XkjitL z9J{M7a?68z-q}=u*9l_uHLarSwa16PKc*$-rbQcNBnDH-LQq5DQ&HPa!fLr3?zSX! z>~C(_DGIgoQfB2U%pdVHO( zYJgcuxX34I3#%Hh>+}8*ZZzh1Y8v5!Ywur=EYmsD4r)|I&i#0`pb*{Oz40Ew*5U+|{fZ(H$(DE~B;j0< z-#Nos4EE@TV;cQ;8SvjzSR$kHmkN2zg_Z-2Xb=CM=^ea^HHdv|&tF%0ow4>2xb&$n zvQO}uMCLh9WS1Tmpr}`j*d*B7=aZ#(k_#`fa53w~e#@UzG-F$+c!{Yp+4J z@I9xvTp-~PVg>69OLS&x=i;=#Z7dDVStuy&b^TSqdrdAi;8?sz{mD3FlKBK@5G^4} z+EP<3TPM!t66@g8&&6>84hAiotOc12@IhN!$`Sz)$OBUr6Ha-~Z}Do|IfQup{vJki z4rzAuxnZyB#}Jo?pioRPL>PavB3a5Q0QUMzoc0-onF+Eizop--cP=H)3(A;~a?KJPl2| z?}uk$oQfo^p|P>CEjSuX-u|a(Rr&;^5uXB96m~vF0zD?p=otqVB#;vWb*Vt zf{aGV&0}uy%U66FeaFg@d^{WTp;m;QALp993N z(Zkb3M>m=u&{5J_Q%R{~P@e4hfS#)lEJCx3J{kh+l`d6WYGC&^A?Q9C8OA&uOvwn; zNCxA8#T>VI?926Le1{627j;`pooak0Ev>bn^MLoRwPQrN6T*^21i-YrU(pFus_)#Y zO-qaq-w}jH(@Rj!HoS&h6%Bg0Z}8OdL86m1^HwN2N&xV8bonLt320*X+btysW61qO zk3!F8xU_czsMF^YMj6FezeCQMi+B-ptI$eQU`1xQif7LoizM$h2l}B>AK*0z2x3lv zb4~3rvsUqF=}8TA|2mHI&XNS}|3=(AeJyCS9aRM~dKg&o?u7-I@74Yu)oFiyuiA=e&8|{p@!? zdvnr$^DM#IH$?(&?DVO?vn>3No0Y;y`FlJKW56W-a4u074)&PC8q<$E z`FUHkg5~dF_ZG3$o}D2&<#Yacvj0>ETez9iT6giNYxFU`BX6_rW1|x{OWBh*0UsT= zm(f+u{X^1P*yS%g7wv-QnDBgDMVRt+7M>JLE>5*+ce2tKJQ6rKgYWr1Xwa^nqZ9xm z*7=I?UDT8isKg8xhuw-CZF`_4R!Qg1Imqfr%9&#fEk+AE}SgMkn1y1mMC__ z6j@hJ3703nF1v{qbRdp*Clu%{+n`AQO zQ?+4P1E+JL*=eql4XYLW#5%kd@P|`y{>B(W;H1ZEaq(ER|IXyfbomtj9LUOg&B&qa zxU4&x&7?>H9XMDDK9Oom6+yG#>es8<7k9c5_>d3!p26U^##TC3`>Tib?|(=zB2Sy? zyUa>b`qn}~P6VO@L^L5%Wb2Rp$Xz*Bt?$APh|cMl?XoiFI!kZ0e%gmne!XWAc8eiTSn~-GWnvSx?KxyAI$}z+_WyjOWHO#uaq@Y*I2>9`M zT1r`xHcIW_K_O()waUksXWj&G!nlYy7XjzWxNRXf?+dGA8diu-7(B450P$~D)_cll zzqo4eNB7Weljmgk1>p52n;)6)v*1SYTf<6dBPuO#3isA=54nfs0=l)XoRADxWy;|~ zJ#+oq`3GIrJtx_%_X7~J;KE6VuiiVAnn_0~HU_R1BE4E3QckHN`n3Xpq^>$vUE^E# zB*|^6`TL%^Pd#j~X0veOdaIxgn<`qnn7HYQ$vO_~g`nnpD@rIJtPtbci$0iPdWj$e zRRsRMbWJbszXNlcBSh|}$@p;}T&8Mr0dOt=^W@7NJ60=AYc*^4h9;x`{3PXH*@EUJ7DOMP7fosOZ$?B)aCowJ`UV3AxrDCsN)0I-${=A_yLYN&gUV{ z()|K6-s<0zuKn?~an-=v*Q4pg?*m`xyhOF(kIknGf~c~$lzxO*J&+9O%1s$8d>-?2 zom;h{0=4pN^B)rXc8Xjrso7D4&dB;2Q%-ur`t@F%W9f}S6iTiA^Be0vBB zBq2HJRov`>;FRkLV#haC(!4y2ALtQ!B4Y2CII^NJDrQn)pc5)$T^YJT#L)TReYpM) zE$o16Fq!U?KlgaL*&Tr-nc94AbQX~0$_XU8k}QVMCxYww$Aq_TNx%1cq3$8apOV8Z z^_3qCblzIku(T8^s#kZSH`$oAX`BpeCz1sv89jPaIJ?o${_HilS;QMkB|>1yjsLFO zd|wdT+U@he3N4vgP2Nnh7PVN0f?)bbXTEhT-_Qyq(ET|t6+ zx4*_Xig*=qnZ3(>13-=cqf5Y`YoXDpS^~~Yz4S}ii77Fj`Y0J%YT}kLAI&*wX{hl~ zMVk{suRh1OApn7BCaMD!3!9e98GIfx)7D4|zvLt;3f^%4{OC0LmD$X+RkP*HOf$6Z z=ZRafylx1+kuC1WTnl z&%8BMJmGPETx!*5H}6y8_tU$u*E_23ZtWhDNnag|M|YGt|_Lo8vXY3ERluSgk1Zvs4})u*eU~l#vmJds{(2 z$A6TI{Z6eN$P;?_d)`L(TSSuo9Lrns9EEWK*5!phVsJ ztv@84;{op%vdDSpSIB9)$Y%nwI3}#<&nu+ zXTI53!_g=Oa%juo5vD>_6pBO$ca_v~MNnT~A+dAb7}y;;=>_R6(-)jWUs_WO51b^H zyU~E`5Kza)=?T-5CP&CZrDbv>FHS?^M{3mqsxGUgUsupD42tB#J5ElFr$vsFbp3T$ zO!eOxN-N8P7&le(5K0%6d<;Bi>H()}kq_ZJ8C>d!?||ll6YS&qucN!L6NV-ITTPJ8 zpsQ^DN{d0Nq+7ruVd%<7%wrm7^2=WbCav+Vnkx{695xFfMZs9WnceBs?=ak7jb4BP zSK<~%yn@9Vxm!hE2!rQe9!^yMntioDQ&pNgVS`hY5`wY%Ti_q3oudceMf_F(G3$lE*drt?T_sE2Gs=o+)edoIp~?FFEFh=?vV3;-E*& z=GXdlYrerpd6*;0+nY*UC}oKFaWGaLh*O1{f*KUoB&|vKJzscI zS`#Fc8YpSZf|~GiY5Y>G3f`mq$AYDTB2zp%;k@LiI=(PlLe9JTyCbQvCTg`FXlCyB z?XL_KS7si!1GZ`)bQX5Yg*Ll&`Z!hm6qRmJrodgomn=>wqf9vOc%hm1FLCi5iM!(4 zE41GYe6%$DKE#HJ(U|9)A0W zL~7TdSWZQGLULQj=hs&q&2aKWD>F4aU~=ZUe_r&B zcc)5RJ)f}F_UTH0Uocc^m3gZcYfsER=7lFeW#SI=EtM7W@fjMa42effIf7bL+0PJ-|ByuS zPB5}Gc2H+vysWs@1KrLK|I~!|+?_a6!-X2c?SUtuqq;rf{_^xMR)lEzlob;+RA<&; z<9WW%R_Gt94{7MAJ>3*@2ejd=rACy_y> zQzywOLQ=V{9^V3PmRj-_7&y1nVd>|9%BK>s_1OIXPNwQgTn4Z%gC&G51$fgxp229-c?2KLkdTWVAUy){ zA>0n-R#?_2PW||BFn(^tQZlZtMEDyL>_f%T`_mC-VJ7gHhuigV9J~c^d1aWJ+0Ost z-zJ@TV@Q$H=BD_)66?d+h+EH?ygv_Wh)dr(2_DU5p17>2NQNASCXTR~ELWQ^pbRrK2vV#3ZkK*oH(gw9o3eTi#R#qr;;ZdlS zA;^nyhe~195J3?bdpEhXb!ymT zJ@z9|5AqC)U{k>*03^UX)Ro5v3p7VB|9Z8`a!_)Sh;=uAdANtQ($hqnK%uEJY6=$O z@1vqd!JB->Gt?hI%ltfyRAuNS?U%`N3QP_2;fBV)%Z^j6J{n>Kr|rV6>fP#SJB|%C zyf>KN&6Tcw;0f#pW5dKZxkNNa;wEQ~ULi)|e=?TiMb4%uH-qPFdn3@Wl-irYA(C5yXs`+H_(>f5NeunCHQf$+ir4C+JIAzFr~#U&$?bx zKMT?$N5En9sQ}-W&`Ub`-1myl(Jfkb)1JwhqKTG&(rtu&tgKG+cxo!)3Mtl*e0109vSP*4tdf&0hz6L^IM${qcPW2f^mL4M>fJnv9x^!4}A0pui#24(8Zz$+ZjY zQ0nR`sw#pO8+%%fX_O;}6++$FgqFubP92R77HQ;kkyfc%N#3iEd-DQ?r3XgJ2Z{Bx z&cC?Ps0fU}{gS9)HrXO8Y2C=@L-%Kl@g=+;Bc^xnn1FoqBWauQh#oLiQw`^`z*>BX zT3?EfJ*>)G9J=U5Nb#O0Jd~KU9=c*}Jce&ESvyll` z6oGS?pad)T>4+DhFpp8H7{5nNO+l!SP0%j^l4G^_&&OOT$065`Jr>e~#&YcFY=3qi zd9Q(ZvUHH>goIJ-Lx2dFM-_>4iF3+DU;cMsDEdn4Py05cXgR9&>9vXliR=ucs_`q* zR3AflD7+1vPfg?NAm#Qm2*!-UoG|(ccaLHpv>9uW_nAZ}28u&dOn;MQ$|wK$mGlqE zRc{SDrEv#S@f@@4_Y3SKrb{K#v~*TvwpP|WToMFZbH=D-?DW{^-3;gHMAS;DI)&&8 zs9W-t#wG_Q&ZIdN&A*m7RgC8}R1_5c`bEB}xJgo@bSE6WpyM3Wtz1u&%xzV>6}c?A zIVt1!?Wkn*ve==a2CCuR7zdk$^2Z`Lbk0L`&kI67i!(Ipp3UK`nzs9eEwYE&9`_sF zJ1Kz*(67gPLogF7UC{!)zx=8v@@nF=954+u`6U84l`7B2pxYi{gZ?wj(PGIum>ROe@$Wr z?j0nmRcdC^m#K!jS!pW@3J!H@c=etuTPm7Vw(D^Rdw~}BRG+5(U3O=0?^etf_3hLu z-}zM_Tv2g3gJrr5Qk&o{xgN{(X~)<58>N=locC|p6rN*jUSt+O%6Wt(h0*cozvEJU zCI-OvCkKZp`Dw<$xfQyG=G|xyA*bo z@(`!z120t!8^a1hRI6u-C;27kP%5ZWe5pO^!8Qc|ew+FN$zarllH9~X9>0B5(>ung zS-vj=4QwRSf8~hQC0oY$I(PfoRR(e+b$nqFXOSQXX91;-pz@=hT+n6QPs3PJWu>si zJ$GjB5^MHfGF9{zAOD_@WcRiofK3n{X(z?A;jUzKlvTUI(<~WBT0HU-9Xw<^5h;wQ zAYg}aSt8~C`D)r@!E>r$x?ooHTzju=SL)`^`}3Mfb7mFL%j}Xp{XLE1ZH%qI1dwexR6VHBT1TLD>{n!SEtg} zO432D&rLO!6AL$;be)up3wO1+r%2CSNywy*)j?{i3By4JN83U}a``X*cL zW(y3;`DrFq(W6_vI9Zc)shjV~A;dhgs5uYic}aF&4E4WwHyvV~v*fWHyl|p59J(B6 zPI2NIKE4pC)`IyG_2q+E`JA1skd`~aazA4f1<{NFF5N}Sd;72A)~+TWFFe^`)tf&j zAMM>2tSa62Y-)+iUaFuIL?~rFm`8p!3<&OBn{8O9Ky*>RbJcQlmo+}mW3=+yzTu@+ zX56GxW+8bYfBF7M(8~=OVUUM`IDy1k3b(lU-l410QSsqiARxcVnXYOie8ZpV>RhvM z&6FVAu9T_|Z)!#4R5fZvvJkC2FlpRr%cwwc@GI>gPb%w!Kgr+z6x&?9`kIYxs=C4^ zO;hKnVZI@=XuqIz0o`ZMx&T%>WYwY@1p+vEGzxW+s6W86m(v_2)h@trEc|-yqNX+| z95$xauzT*ir1cZZp3W&-!Ni6DI9#>)w)Lz0V25n zm2qd~v-mjwV*5aHn__+rDt!ZN5xTwru}gby)aPV+ zJD=BE@Fj z%Fd{P1Tm{1K1ycaV9K^%4EJ|e!7*!a<~4}^!8aihsVRu>r*ciXg6Q#)0q{;& zWJ=#uca`9dFHyHr2SSY)B5u$qD%kjYRw!k^zEgLs$9fBpd&X8n-YDhR@S|RF?Y?!bB zkgIS`1+;*RzMK1hA;@>1G zd@#o4#BF`}iF#lCG^xRNhk&%Cpm$mt6zBWiMCBL8@k;DwJI2OCbD-+sJCYmsnKNIsv|@(>*WClN#jeyB%5#ql z^5zA@Rn>RQ#6JW^&%w0ES3RU^ON78cdQd$|tif9~d+6Z(aK}Y<^Zt)pV?>IK?{#IO zuoTONil?@knH-ThdP2YnD+>C9M-{Ng;9NUfyCwEZFYNI5>v_HVW~Q#6ar1P~vPc1= z8m1enp^2p2Q#PqQUHpB*ggF=Bbi~2>i-?Y@hQoKs9h%HlH#a6%+riiRvgDzUW8F2H zEZj3`OAoe;%sf=gQBDZyH88xBx=+ClJREL<+S7PFx|fjT^zlT`X{qI?=qG&_`5sE_9kha0oSqe?EE%hNYsiE z5pGD{O4E$@l7y53WK2CEi`^g`tqxFN!AN4nJ_*SfF8(12Qv*XC2qauf&8xt)0WB zfL#JZ#nwkcY7}e@ylqCd(y+#8;)Nlnc|cc{mJn>oLKRm>*BLcHoufSNq`Io>1wmLo zj&(6%6;e*B_%&d+-XijlJ{{w!TWaK*Y96g!Y)~kCPQxuzt>Rugdxy9hZ;ndCa61)= z17^?6yCf{m9+vhYwmNN}oLKs4Z;g9c7(B4>dlijG;9 zkvt+yGCHaNn(^((3b*KRJCoDJdf%7QT6OG)?SzGtyvI7WFK7U#Uv^K=PV2y4UQmTv zu6q>mTBW+K02s|8z!fOL>@@zBbYJ{0rz8Ww4=uD(D#7$&7Ri1_WhXt4I6%g;R=n@E z5am;02$yx|9^POo|7$sN7I8l69=$|Mjo8PfiC=v!jp&!g_-lKAtRAjC3+5pMhn{Ec z1F=(1s_J%se^7KP23+qmj+m zAQd|zOj>O)#Pgcs;n{bQizwwHrGX}`!exVfr_L;=;DZGp4Z3Vo+B3HbH z0PthDa-B~b=$|fLI5ra2-}(f^lIe0`)F*H}MX0mnNQM^=X%6TAA?YhSjjCwjJOha= z3uBlwGs?*$+e|8y^L@>K5odYl^EE?hNs9&nc`j5(mm+-;gfw} zAaTo`#Z%Hc$@BXm*eNN*x{&{eYOwbRYa>mxy(iWBQ~wW>Uhp1H_!Yh6oL-?wjk=5u z)VE+TVtfvk8zO)lEf3U7s4!v$1r1`&;BQKlv&{;5mB2 zoXZ=I_=21U1g05th+5&Uh8oC&2!NV^Xb&Ji-jMw+RnDBr^FvMv*Wgd7w9O`oySi@G zJD}Py2JGzHKNFTyvIrOnC-eyw^@TAQB*7mS8(gn==z6u>{^^?f@Xr9_uB8?A?Bwe} z#+~8Kp7TOk*|C8IYV0L3*QBy{qjK!N=O8A6))KTyUVab z7fs!stzIP7`Hoe91#mDy9ja$D&uRc2l!FuC+fY}~`79hpABh2jATcBU*Td_?R*gzl zFAw}ozfq{uZih`2OlafCB)v5-x%Qm(Cy2DOIzP56sLV zcgVClP5%~I`Oo#&Qe-bZ(_v7PNzp6mP8G2qfTApb^V<-jBe9O7mimMD9b@7x4#dRj zwb#Zn-Bq=HxyLEwN(Gjb4z4tOwAEBl8OndI(Ct- zM$JkMOmVfEA?U}=1rF+tef$Z{{tD99M&H~XY4&inN_Qph39e+kH_U!D;Us`Y@@Kv4E7be9vRTG-RynoilCcbQ;<Ze}oSqluDzqZ(nQjTzlYCc+W^xGfz1uQv;R<6IU!l!IHug zHm4w1|M4s9kRXEB%n=)Z3Ri_n>z!Z3im1tsU?+a@rGpMfzv<%QsZrsUCR;79k^!7` zCFVqo7;4s_nFQxKKKS==C~#@;jZ3h-bEi<);V#3%G^oE{>Xv#!*Wyd921KCvtf*j# z_PdqD5^X#$-2Co1I{$XRab$sNIuBT=YeuDW=DuH|Zr^x@@kFYWkz>qXiiK+4^~EZe+>iO>Tq5P-c#;5A-0OU9byD9|Pw=kV`BCyRG626GqKPWy5!9Ykiy&$i zk7**OQR)0?>PHS~)Ox_6)V}Hqv8#39Kqb{=kCJASS!K4a?4@e;y_?t8{yi>7GKnO)Y3xg7~7G=HStwt&Z$Mk*Ut= zv_Bc!ketSlT#K9rDDlrfMhJIN5tQRGkJj$FqY}b_LYI2*R9l8V1)J zpX|x2`YICWkFHrO@DH+P9DN^Uz}H9Z2uAim(Z-wUr4i&74y!|-<2D;P4mISpc*p*o zHmpE_R>Ks;dH&x!eD#K$Y+vKWe4{5Q2YJoxg=rUbfpGvf~H*okk6jUK?qd9 zl2r#*!(tM-0c#nex?1RG(4QPR4@q+=(@NNrdvWl(0V!c6K-`iOw-LnBH#X5hGyG&u zNXtrHy-)+1#oe3S{z&4cc-QK!jx7Jt1%bh3?{9Q>rV`g(CLR(z=p-QV(mxY;8ije_ zAnu0QM0rgU@@2RQt00&3DnT(meaOX4aBRHWZ#+U)LHJ0THk;1ldu6k{4spTUfc3uA zs^)S!W3~<)BmwZxt0~AgPuP{}Jt#^W$-fc!4Frv3(g-UPC8M19jdg{USjyTt7cSTo zhLJ}A%$Su0B&$kY5p;#yKZP!sKE3B?;XNxq(cvMmLUA#J z7n(7R;@5C?xbd!m~K@tjq@pVNNjLJN#7^9Ez7?B#`jI|L?voaM5Orj2Cab_xd{ z5Z}?=UkL{QJA)@pYURM+&o~4A`qll?j1Z&WF{_hy_17N<(2tKhsk^oiODSO=ht0@*EOqVTNzUktTpoJ__Gt6ipv z%_s4ufRcUmwfaX z&02wXrDBPlm3<*Nd_gCY8yFe|1COBhY{yCdUatv)Txrfjyv4AF2`5@$vL$B2j{SHitIGwl1HR|c=F>> z?V5?lt7ta8Sk)p`7vRRQ7;-~~XV@c0r{+5DGOh!Se{-Iu8!{iL`#9NG&(zIr?jCU- z?;bhE%tz9E;Wng>bK%|>KSI!^bhq6HJUVa&LMD7^;CLnn%%@_m6Wjp4i>Iy(BbUY9 zsBouun9(!#tuOtph1YThAX0hUsAc0}V$jceI8?AWr2gUQ?hH%cM zK2_&M6pM=Y2*crVX8Ef*?UyuQuiY(VxOs|F`E-TgpHD2IWpo>i85!9K%+;-XfVLRd zJLLjv;Ld*T{0|A_Jsn z)SMfz!(t#p*aHGki($Q{zEIk2(gMytti=&Ok~uLd)_cW}C3QpUYq7f14E|;j7#EX1 zUI_5YoPU~-2bD&ma*OV>(x+;~S;~CFQchuyI*<7g?W|k%(hzHvd*TsIV6%_Jyh0FI zC`m7jCMP8K!o@!AyfDQ0{tfG z#r_u|;s^&Cj*+uxr5CQw*QY1n@oDIE-5cg#-;J+5Y%(o9No6_7i6$fWd%rqtax+Y+ zyzh2T>m{$>If?n}*VOG&z`!19(__|N38c%`5z8na?8SV!Tr6$VTZSxZi~GW{lt>0g zj0m9`GO!sF^T+4Ly}$q2ue9vlY@-}V*qE=!ZS0VT8;kFBT9nU^NN6UU7))?!KEr1x zBz9raPAbs}0Fwqq&npS3y}3crwAm5(N;kW;7jQSTzeue2HrQ=Uf)pE?bDBwQmm;w+ zbQY$29^qonqLgi%PK|O>SC^3*d?qUDoH+i(>h=RuI;P`Ndc6WmN%R+&rWdd!qJ3|s zFhIrp%W)}eiEqK)MVtt`K=30*))LuCqD0ooq$g~N0@HMOw&rPNTa8SSx)pQv6g9cY(6mFw zI9-WVO;G{e$0!WdVP2sLsp<>SyZ<+Q=zo2RvuDr!tCz|LEO6P9f#;iQdyrQ3#_7ivU>DkW z{p|Mseq!+5jFGw4*XQ~*wkyX+*blN8OIelA4fNg79eCJ?hB`Mz*Lmn6 zM%XetVQ}!(^J@`9j(-O(@u%R5zfXca24PV@9{PSUVKA$I>n1CZ-kfTQn#)A;4xa`B z%p#f!H!tX5Vqb8yme_eh-KVh)M8kY+y%{?ELHN|HUXihfxrnx^ZkKicgXUBu4*y{N z=6s-9y37d%(jvd0Me!~Ii4-@|9D347#wp3cU1Vb~fgWtOdPYKWB%La5w;*pf`@?N0 zU`S!M4pAEPV`>}uw23jUpQ16pmRO`foyMK01w*B!l&Gq+y}wC%Ix5Q$++=piYMEaY zQ84zS(J#Mp-g?(@oZba%opBr#{?4&Kn)PhA3zc+tHb^dGE->9M+tA8I56~_1rDZWj zn*z^*YSBWr^4DIyj!(4PHWw^yPNBqG&EJSO!?0wMQTItCwa$9Yt>}yary>cA2Q{ol zxsSIx;_pNl(Gt9@e}SsQG2vq-jw)qXRzsmH&~e> zT>#7pdK70ZWfQ(lcQwOwX`s)T%Zyl%8K0ZF8k6k*u`WseI9QLBfF3C~WHl2JDlX); zj3JQev4l%f>uaMvn3&vzY`oQOFL@d!x=si9y%WFNyeE0-s&EtUe^qL%IMx^zPfGlq z)7@-8cC192NdTs_TJ%i)&3OiuSpv7>yIoa&=E2@&4t)V1=Xe6qUJ@6rf?P5m2VTDJ z05b^Ak5$N>$nYpNhq*d;)y+5A6pJ4yRGV{3Q!}T`Cjn9f10f6bFmM3`obvv8JH_j- z`0)1c^NT-+3wZ}IwLoWh+Ni5`VUPYtub1%eNvP4+-O8pX z0s~&7tDnYbI0g#qnISZi{I|#W|I57zaG>y=!46gCwhW0J;CE z@Nwwc5{J*kU^M(tHJswgd7ChlRU_vbsX6J9#KF~z$DXxY&WGws-$XVC`R!(*9}|sMA(5#+*3rw$y+Gj9qRW?2DE@b{SoLN=-vqJD`JN zgriZgh!ihXRjZho)`;hdA7d=egDEc}|Kzn_y4hJLzfI?VHubZ+Zj036EjS(Ck8X=k z%s$Qh35gta+HCU(LeD%(7=i!+-g#O{&tJpWU)ZzIy49YbZkCAaEuN4hWZ5twzQxI;#-vGH~RS3WWdfndQ$+EZP@tY*JVt0U+$`U4f&Gcoth!Cr~1}Ey8Ojiw?Iewkv`En zo#m)%n5FqiMCC#CW_-)Jb)UzvGNX{W6^gjVrbrm*u``n(WRmx}y^{k)FNZzS3r&9f-nj`-^M zjV9}ZsonO=Z%7dum?&VE6wJsDLAjowg1sCM-c?z!_0$|-^0j-u{hf_eFN796rA~)n zHMF$VY{~w$`EQIpW3>ooqjJmKr5CCOCJK^%~EbM z*W+~p>)EaOwNmZ?Qp4|N$iQjcmv$BbO3H?&=~_{Q6E%HQWwF7{gDiaZlGBG|&6$!##g|@wz~;?s>VqZm8Q$$I&cu zhk=p9GjXLdz~=*qoDUNLxtzhI>}qzK9Ou|?%^Q5c5E(OKj4Z|-VyY?YP<{2;umYEM$+v#kbEqvCf(+~^T3hS!lc5<2t!Hn~0&dwc*~yz%80F%?Nga&YIkujRIoxepmDr?;w3Oc* z7s!xSkLg*;0d|~6DbiM(P|-Otn|(zsPl`402Z{KupZ8n`_1U^|Zt>1bl#ik-u^2dr zOdB+@n*6u&c==DV-)m-V`uNCTm1e0e8XwM_z)P?HdM1o z!%^E$U*{D;onhq$)6X+Rr<~w}e$}B_`wv=J?8_`2vVk<{l}vChHEd-lY@^EBFzU>l zx*F)wk|U`A9P!5Xr|aq$ihXGVg`ahcN3-lE-%&e_S3gDB+s_w=X^QSHCqDbBrR#N2 zA%TVhlZN(VACS}NKGm0_s}kWB<7;ipDItP}tI5w^QuN(7v(XZ85E zVm#|SurtSwiL9A(%{whrSYC*Y5Vxn~>wr|HA7*bwXmLVFQbO7qJfZeLsv6RF?>NxS zKo=t@DOIf*Ktjb}@ag~|R@BmC@R=!Uid8&5019qLZa%>G*J&@8zPvj_|3S)1_?9rZbn;g|u4S8p<= z+Qz(LncL~Q^_vx$nfceM&AxLIS~X@f7dZ}HjeOG7VFUXZopkB$u_+*mScb2 z03Vu%$Xgw`oZN7#yjTuyjrCSGEkw&$wsu-xEtLSTdY)DTPRGO6<~D-QIN+D8extV` z#b(Z1;sVj)aRrY-f#EYz`QoeLm^FvTnM7TVI(C&dJ6bTz5JmoSMsL|`B6A@u`VxP` ztuUW}?95{@bQ7mk@nA`s9LV4p!)Shd-Yh>0$x#lXoGvIXV=bQMxwCfWPTaCMG6r)O zxaxd*T+%b)- zs3(K}ki;@96GmP)AMbTNP-SPz`#w#3=H~3`lxexZ``2!^FBK=?tuv=#BJHz`YBKk? z6VR6J_$@)eHW{MP7lGXQR9M}Io*j|0^0N;PDm+pR8mt;Pi(~bgjeI3_Qu*ga1sch7 zs2bL0%EA;S;ToMzH@aRnw%c0A{GAkh9q(cnPr6#yT<@Q9djE^}J{!*4A*hS=Zn4AI zG}+g9!HiX>ynCQuUd3QdQDLy20c2YL}t<|tPq-g{N*HMPi&ajVX-yL`CA$9C{llb@k z{Yc=O)zaHnIq%psD}Ko@v1vY8J=--M|K9m>#Fm`W>375L=;q~llN&mKt&?~Kk{xNM zZX!>Hkd!hH6eAYy7LU6~Qc>med|r9gVt1c{8d+(c)!?D&Iy2xaQ01^7F{iOmvtK1UW?RtJzO`!wahpB4UTLJmLNx;Z1n)FGrr;4Ghq zKbe;I3oq}SO`Q6yTzWq?N#4>~ia%GgdVTUV^T+iwzi2mj>F|VFrpnkhE%&ObzNycM z&z!jl<(V5;;9ddz!q-L~0=?)i0-1z9_j^NfEu8iFYn%AFUrm_Hl8!M#>zUT^uT#I; z*DRe(Nko&V*DedXKU{bHd!O-Q$?Lrbj!TP-7lwHAsXVi&ggZZtp&nqen9@5K1VM@k zAy(Wk{rpSDtbH)9e3UOP!l&gaN(|u)tY0|`2cIS($l1DFR_5*bo9qA8 z*8U&9{O``x8EX+rnW(qUYGB={rQMYKDQ>KByIOF+Xwh@X3Bx!eBCwHVQ>fyN!H5W} zrl+8X^JXJp} zW}hfqzON|w!6>Q~HHRQzALP|#;rh58z-t$&A4jj5D2VR8ohmrhDENou!+x#p!+?;v z4ARXYwg$iAmN&m@U@5|J_*P@y*lsHQM5244a*&StmqhjSW3Spnh)6|VBq_hz@fmY_ zS)%6Ob@TS_Ne#Z!)IUj=#a@~F82e+dI`;cf-KW+gQj(_@1C61%e%9xts#2xy77iPY z`nF%64$GJu3`rd&;nrj@C8}vDemnvvI&Kt9BqWRi2acDL^?S2jt!Gs!7F;?xzs?+v z?yb)@d73mij$};IV=an$BK^li>v|DUnRI9n>$fD;q^*RE*_Rd`3<5A2R2}sNbL8*S zFlU=Xp?%Y)6N6|w*t8hnifaH&RBJj(<6^sN$CsrtjlpZay&;}EM3t4|b^dQj zCcv@0`X7?9+iDLIgFPavLT+2+SgkI!AMn~_s^DxJXfvu#oK`ozv-+_U!+8;gIvGt3 z-jV!6?gjE9UWLji_3)D9K*fgBerK}RGp z<+EucUHSOZ7B#7u1%Mp?VU%C0o^c5?OVyPd@LEvOEn($F^Yl=sGo}cuTB%dL{<65g z{bKL5{^$m$gTjHl}Y4l;w)La*h@EwCOZv zva=F2M871)1&8CRh&hfh3{LgXvXG3X%Hn7B4Bwp?v#AK5f--O$8+ZS_A|$2 zf~ccmwW0~raK=7zE^nRMSqm_3Tvfe@JCj#cZKU(L!n^<3?<#pjwC zWN6^LA{4}NfQU)$`4f2GNIY&MAdgAQ9JD9PMFZW z)6CVz@Ra4n?MAnds*b<U`;4MAoKAJRFdRbJm4zZxZ;PhnjwFqL{%_GEy>mEyGTGSn&FLQ!wyM^STuQx{ z!bl^pjXxEu6V|wcBZ3;FVyFL_jX0l}5ge;GD21jq)fLBZxWdRf8L9iY+oFclBO=WZ zvirrG*xi3foc_v1NuBWvA=vLo6&=sv2Va%2Lbr^2Q#|aOQLIDva$dBGZPsk*J4kYJ zQ5ZHP5{SOY+LB2I7%_qw&dr?X7O?gI731s4!Je5znH1@Ns@-XsUWkdg*LHv&WJEL|c#4@t z>g65FO04*~xOtu5+%?`y+;80y+ATBsE747ir1t1o79{dkBD(^&GPQz|YB->KTVdOh zaYlc42o4>%rlO~Fj!@2~Os=dH0n&CfVNsLI1|944R)hpfFxU5)^A&SGue89t*PCE>3UMP=pV8+sXd4h>tXqI$*} zkd_P3e9m<+kSKl&x8`AgOCuk;GTzSmoF>hC=<~6++wScmw^NPG#8rjG@K@thv}JzX z`rDuK;lJ0O>}9kKKgx4#BbNmod4@H$eOsS%R1_2uq(~5vDgglr9R;M5P!oC=>4DITAjP8~ z(jk=4krH~5mQVx^NG}2*^xg!5^xpK{=ic|Z_kX_M{fUn}*=x-;_grI)A1n3UV}&U# z{yF0W;v<)w8J{Tc8MA8Tyvh9iqPU`H)d=v~oVyxT!%B!WeL^-E29SBGg*|84t%YqW zs3$v8nn~k~%aYgj?eWJ}uU#5!Zti{f6Zo84SSHR{j$-$cJ-dn;sl>3e7V*Zf`HwHh zkV2d=FD;qgCR*mGy5PE6&!DbtqX&}AZo-2=4sFbQl zy_OXvB@|dmU{Y)zVW@Dii4wu+B->ohgEsZSFYeT!`8HH^^WrmE7X2H~qjcW{`@g8a z?fo;uh9sy}-w@?&u77kxY}Gv8*S9Ahmd|zuNe#eoK$gq86$mUq*jUkBwst0F<(~M_ z;hiTZENAMAf{Va!v%qp&_BYv=&m9&h2jEhN7ie``s;icm#_EJ9tAE)_tB^0EK7WRPI zH=vxSqAV^y=C6;Hru2!fY;@^8*j5Vnx=}n~w#EHc)LPMiJZY8tDOXhiyyx?bQ;&cr z`ka}xKSba2Z=n6%ErZ3!M45Q6Z`Q~Ux~Z{MtHVN9@Zwbg%&X`88#*L)iNr34QMFG5 zP|Rp=Qr;22;(K|hbRbgj6PZp{)Z2xz&Uv2)7GcG0%h$!{S&;=k9>d|DzsX+qKx@k# z1D9==Y6|*Yif>n^TAuz=Y=hA)LU4HvW*X<+7V@V?oYUG?PwiX&zPfrPyfz)hc*gnH zgMgJ^%k$uLu1m1t)kFTBS6k_%3t5NGrK&1&y(rH)pALtHkls}mUS3m(B_HZzE^}1& zxU^DgQZ3pS5eAl{l}ft*HyN2#Onb!cv|Y>WxzExkcIv3dJI@ylzc(C?-)JqR5+s2L z`QmSJ?Gc#~f%V|rFwrFDXcc-lj^|^&sSe~0u3=C(-x%P)5-NCj%I#InewinX5!H)e zH=!&P-M)-)J5LB(k06>`cA?faa0Pz88(d}JwGXm|*W~?lt|IEDx5#5I!{T;AySv8B z1eqsv)qND=*d}x-6_K9eHf6kCsAmLY`6LIA(cPM+QIvRFtFyTl}FfVL~5|xXeO(QFJXzhnvau|;~_HC9|Tr=+)ek5 zmWxiGh~II>vNgyQe)%>`d)~`OupLcx>pYW5jntuymPfzl9fKBaccbL&D%cp7y74rJl2h#3R25-ivPYY2I-* zoD{$H!&tA{&!}=P{~>e90IzB9+r9(c^17dOn=aTk0ckw|S%E2bSe1)+D`%w?{5vM{ zH<^!sU>9wN!9~wiSF?_U+9{&)T*5+TtbsCTD?9r_ScT|F((7(?E21Wr0OM&JZYRt* zw_Hsyrm#nf)I$L;Qhcw4Nbpalxb?p$-~aVr!bQtZ#&Csub5(ntn7vN>^64Zee=CL! zGpYH^718dZ?n2@c|G1ew0Z;L?1H~hk1=CQ22d}DVlslCjNKPXcu+;+0 zIi5Af{_}3>;Y(Yi`fo=b&5F(3p@}P)=4*DpulxKX3j7VJ_9cK)_k+XTf)G(64&Ia3 zJ@5kaf+_NQU}Kd2WdVe7K5wTtO8m6u(4#QJY-`y$Gef9-y4WTvsMfrU0M-CcmV~S$ za*LcGXwesWWrs}JoX8Ch2*>hYsXG~)6)mybuP#A)LE?Y-{d!0hh{UZ;;&m9kHWPo0 zlirv|=%u{Us&M09chq>KPQ-T9_h!n;yoYlEKrLJIZ*-#Gj-f{49iuu;&H=jCU{T%< zxs6(i8$SIisTU>U4-H_ZoCS$I{}4$|KZTs1dU`L6&Ph*hA|!s3@x0y;!@7vUi(`NK zY4j#4ps@mR?6iz**n)B?s?P)Mt0p6;U%ksL#M z3Vz?Rr$f!5#x@!anR!{a zH-D{~F^tA>>oMf1W4}e7faN370Y`um( zClPGJY_gA2)pqt1;{#MCBG1($GG|iWl3BsLE4^G0sYlArh3=raSW>!gkeElrKRC+M zCvOCP@K>}Rm1XG6Nu4){Ob6IbUA5Yc30+Rjq}l}Ltc*B&+AZOx%gR>48s{-j6QB?- z@0G@TCE#gK=bIYoM^fgYkUgny#VCTZL{XVERE9c)>jS`K+~R-$@LlR)H5ZiiQj^aN zFvXk-0(82tL`(JTa=mGlz|_N(DvNN^y*|NgJ~z&f3`iqJs5^S4|McFJcAI%vH-YhHZcatK9-}tb(eRWfEjMNyp4l;X zjN;3y+>^d;HEF!&mz^1pDlqu62xm^TAD%Cg23Z^A)h#`;2?nF`2>TkmkPR6+(>M${ zJ;gP{DLnSOE%v6aBn5uC0b^t_kiNWOtR5Nj!RN!aqL@yNw`Y%E^0eCRHUBR26U2Ib zseRR^mmn8^I_Anc?PEe5BR7;ow%9+bQ)um)bHLvukCgoOp&w@A)>27RPlP%&C`j-) zMqZukPywweTSW_|s83sx@7(p1aC6`M{QE_4uVxv_;uz?inQ1AAUw)I7VmV41nJ{pg zy=n9_+6TU0d)!Bz`W3w9=$;^ENP2iFYtgdnIcjAp#zVF$6MaQo6IZzeYRP2&aO_r? zKpNzeM*37!y%Zh9D*$|87G1*;fBKKZ{$0C(`uKKj)eqo$ZF63Ce0dbPCD)U9lfw)$ z;WT4hFY|HQH8vRY1&#hEVp`-W#nU3Z zb|3+v4%navc2~A?%WIrlqdabm=ilxf!<8~s@s87*@8E3^lM1}y7ZeIAY&B=!!OjK2 zPvRFM2j4$tRqGHczcLf_dAs(<@WE=IQcdN6ObqQ_9_>`mw zlOM`UId4w|(6-(KGs-6o8%bt273!@kC z`0h{rHi&+2O$q`fS%_2Y-2u4F!3-8$?jjsqdlA^5fwx#q<}!UuK; zyPzc{<&_Vp>`c>Iumw7w2)eNcLVYVK4Xt^UR%&V%VLj`W4pz>WZwBiV^dK<0RCRlp z`*T$-)8P99DXelH1nY8%weSaLa}5_~cYjc~Vhs*l83!uK7aFQ6PT$rtr)+;M6R7oA zgqeMh__NYj4i?DHuFagqB2e^QV_#vLFSNpXbZzQ+ zt=8TQXeDokiClgbg$KTnoU(?S&n$U0HW~~%_QIL)OnJDm6tEq_YC(LlH zB8J&U)wP~u5en!A+=V$-ZiJs|9CegXCat^HTzJ?yM%O!-Ck;TSw%A*Az1^*YddbC z9^&c4T@Ffi%@s+M!2Kr69!mss25>v9Rqo5Zlr3M@YXSF%oNPSb&(#ln(4_HWRu3iV zXd$_M8t`utBp7H(YT9V;)~ z`(EajpX`PMdOmN*HyaVzQ(i67uH)SKhv$(BUgO3hWo>FgLkW)uvE2AO5s6IUcK^9h z8TFQFrcVr+O7xPab>HY*Hxo3s<}=Jb<-n;%8D=`JywAan?8M5NtuO+!$1czr~rfN28kHdg_afpm0`-=Iq|2_`armDNe#kdGK^qA7Nz45pvmS}0?`k&<8k|5K>Qu= zw!5?l-@WZ{>A=#%A6e?K7iYs`F_N(T zbcKG7aTRh01i>F2Dc3mS6wqGE++1We17hv%l8Vvxd-&~xr7jo58CNYIg--#RuxQm& zV>$-_BW$buWIp6K8EU|F2=7#RbUSb(f4ipasn?$s0!t<4bQB^rS^0=3FD>&a6&%E8 zbgF1=6WpQlbXX?8yw7{hTupC~$JoY?(DvA)Yn}*H-;H(8zSn?UR{BPAza+T`hDxz5 z{s+INRIX@A`~up%?OxEs&3WVG{L4J+G(p|*O?o7YTv5V_MFaNhlo?7f??`OC^fHKu2jSa8Ja;YZo9u*XcT@4mEA;kX^VGq5xFVa68n1plF#nT z9-Xe;`2IbGL9Ob=4`Sg@tfsFm8nVd_;SivXR*TKC7`YOSHrF*2m(}1k6BAJyWTlP|7*>@v>MBH*oWu&-A#z>9cU_`L?LZ zetNe>V0;U&KwoP{>43)S(~ZQM#rOjj=arfz<6Cvv@)h4ryEwEC>-G|Gv!28ntVTu1 zDzp=AzO7ugM1F7h#*gx$2E%5=%g_ z&7V0d9NzvZI!12erZ!NWba9sJ35EUfy>)_y9g6K^3}ugdkxm-g)|^s(VJ674>hu6! zj+L0k7Tu7JH_c+FV`9eRR2fAx&)=1+^^O!;O(gAg*8qb6iwG%^qKYy>NE;oFkf$mK z*q^u^;O@$bDhM~R%^7mf{A`t>ALinZ+<4>!nMY|&Q+7>Lz9=dktDin*{&R9r+J;JN zE9|3ir$b$Ih3Aa7hs#(o(t$^}KzlRFH=Hw$5ssw*E=12{BcYbexDO*^T7;yj2Oe6! zIkBTM8#sRQ<*^0f#~D03;Bl7y0q9R8R6^0;dnK}7)*jGU{og$jA6G!5*;~^&>VK6s zzVE`gS6h!T`KI2Hgj(uYcPI3#ZWWEv#A~Dxm^Y2MY!he`5}tO)`tF{P8ViEnsQ+f)(xFi3~pq*trq4ww8W4jfQJ{3jLS`CFQS2B$`ykL}Fkew4MAp1kdoDHD*9$QaYL5x!EN0gaxju_(78gPNKtpU-}CU*>zIdfkL&D-0Zexp%Su!Uw_Afk5N_kW z&Yw!)o~+gv)w(iTKYav#lUd(<@~>Z&-{jkPbD;d}^rIDCTc7p>NAJU~+hOdv0tAIJ z0_h(1TqSO`mhHsMftYSbTZJITAL#-~Bf%@B=D^i}P(i>XakJUDLGv#_+t_6#Q(WO3 zSmA!P`{+L8&3NskAlQI)WIJ{5>1`g;Z9XLbvT9LZF&*M(2-I5?niSRHMs$#;;2~{r z2^N%@H9BZ9c$av2l}GTY8JGS2;Ha4EHyKE|RY@fEx1)S}|8v<|GJ?h^z zQk}oh0EJAO7}^u=Cb|ZsAkq|amD9Y&A$Oqesj>AMY(GGsh>~st*FTPKmUZwDvAa+x zN0Dp!JRohc05y>-jTSs*6ib@vOhklgWsg~@21@cobx!+wsUQf%Oemj@Gp%~}044i| z%=4uIhjtf(fHG7ey3u@2gb8EASYL{9DVfo)qw;aaG>6%zAunAjehP<1R~YmY&lPa| z>}v)zMJ0tg(Vr6s)$U@|xF=zsh6sH{{*1<|=i#|m=j9vobN9&tNMpar)bDn+%sAZI zC9XU0Sz4nIPHmo1Pe?&W>>EDk85fj~{`fNR7e1%~?>|E2^LXXbQVUnXC(saX)~)-f zDqllf+qve@T537E#_UmXY;6VLn9AjHO<4fnY`r@jMi_H-Q-yOG96iS>o5g$r)Z0FP z1I2(3-!9%1uvgvusImo~4u{pde2s&r&)!w~u2~+h5piu;58O3vIhN_$k${Vxx>o}T z-rdyBnMAh2C~xici!6?awC{=K%3FLzWA`Q^M-&(8FJ7?jJh-*DGwP%HYd87Yjz0eU zmC46XCvycPL0h#JPp8D-%n*Z4C(RGOCL)rY@M3zN$SyZcd7WZJYV-~klB1T7t?dPT z>(`e7KVDgFjRWQGCu9aVx>{VI_&kkW?!04N*Ln3QAoQa1T9W2osq?#)@$`I$gFma8 zGz*t&uJFcg_9`;Px>Y5d3D%}Gg=nYV>WYt?l4Y!$#(hGQk(e}Tan zUkhWjmVc93z9TM>k(C6|F#M{wvhQ7u2tC{i-6CBrdjIWS$y?$lazUJ^Ih4Tp=T(Cg z?v@=%m>z)$!CW#OsaLR`f9+Z6tf-y>S#xF3+dc!iWiXg)Z#v72s+oxOV21f#=Q+#XFfJ$DsV1 zR_-EMZ+=cmvjmuLAMFABrOP>{qrh>Nv-9_%?~v2qnp!dqW(k!6ulW@%FZ}*`Gxf~4 zq_fxvB{Klgz4=g029XlacA|Z5D&rT1lqXcYrjlvOy)%8v!R&Fg@Wbb8ttW$MxS6T< z+AR5nyGSe3%t<( z|J)2Ee)s(B@IljHJFmRqMQ@0}-_=MTdgU9kxr69-o^n+a)KSWlGN@B`Lu5p}fci%_ z0cy~XAGD4m*2Yurt*#r&Sx6Hdu5MBEka^BD!cRAvikIt%1G7E6G&Jgs79D=;-~lrZ>MGa%Nr_&s zg(b-sd`}Swki#;)I8eIl=_%614VBkbGdfJ|9~m7=7G2~=b?j(a^HUWPA`lCrDU8;? z$!6BnT8Iy?`6s12`V1(>196eQnuR%Bj1S`)C;rxo6kawT*Htf&57KUMY#;fFE__^W zIEG>`pe=UOsv2dQ6n&ENM5l)bT>~;08A7TU7(2DJ3pNKTPJ!CHh)y(NJb3!eI0FGi z3Jmg*JLXS11*`7|py&gE2GVy02RT{-`Uqev(9)yFrrp+PM^r_@%8Chj^?(nJ=3dhd zbD!qr$VSFIzuAqK^|Z+4N>(#L{MNDT+#g*{|EjCEXC|lJLY(8X-+Ji9@BJq0A23#| zrz?4%)Ys(J>u&`!cb7~_{Th-Q2IV^MSs~b#OC^TGODb+DIEz;&^2|7mjK*%}16~TX zvhoFf;4a55$UIO8n5Igb%o~QPoqKnpjK!k9@rE&N9;axhz!NSF^uG-j!Flh91cTdo zhTcO{o#8A1`q0mBwiD(S1CYK7m5l-<>eYzoqJ8$N6S zX<-DUTwWA(Erj!`_C|$rx}9l#3f=^vYh=8%$}2$q>r7SXOaw6d01b-X~j^p~~Igq;euc9Pt5#@%z?eWxo50@kVMfXow-A`M+)E2J6Q?-+Boi9{f zTU}+mj~36lyUu&?Kf}DI8Eg61GPBhaFyoX8wC3R#)+E)iv7R3f*Nw2;c_7`q$NfWQ zHFB(XzB!9lj@?oK7L(`sALYh9qFMR zs6_*3J@4{|;pQZdt!sM5j$dkpki*ONn4{DWgJ1fkjhIAOh3NYTvkoumJ)O~d8v6LC~K8V`jXpqjLuX8j*FeL$0=AivDkdVe-)ThutQ}< zI3L2~<7R$LE{py`$)i6jRPSq!lv_>cuqQ}rFLtMrxbP6`7@-2D=qgy62*A=%CDha* zUBs1@Ra!CR<^&v6CqrKN7~QO%E~s1G@FHMKj$kZPzRSK4jAi`9^~I zh4iaUNFZ~EZb{Nvx0jK+wncr|D8XTP(NAhsk`2e=u$Gk9Ezc~^@SX#aguS|x=AA43 zuYF3vw;bp2t;%!ipPmAVfr9}9)Ifv5jlomulaFu7oHnh>hV7guv%AmZ>MI-dn+BBY zR!j{1ZwY`tH^E~b32(+xm%-eGW}J0k+%v)^$W15W;^Pd{HhSuXYKP9_k|uo|5pC{U z0w%ZVg1?PX5>O+Za0u?+e)HcI{~@DCnH%DGehBw+45i-{=YH8G7q#vwu}?dQ?MoE; z1SjCBH$Vq@Vvcay1XV5&0tkpZ$+nmSlc)Q<{yCGBhKNi=@$_&FzODz`+Wq>I;~D*p zfAv8|L7_fDKke&doBRp+NXKGE71fbWBl>%BDH2O9k2`&QS0dB{lr~?P6GNGNQ-p>t zXdk$dPw%citAebBfbmR5tTo-;-4~tnZ^Np5xkUaG zTLHZs5{N#eavI&Ddi+X?KZd+T`aYkM#sR>MD^yVkv>GgP2J>hcz^Ke_0UYc&<_J-* zT_CyRVi&1mb4SO;a7_8<*!AaA9wU!3?591~HK8T)4yt0an8TZ?ZpnQJifWP(+c>ip zg;AC5Z1A1dH>1sT(>J&OfL;2U{xNDi=JVlM1~e%|AV(%=VN{`L*8=~W%rj)mgf5E5 z@fz$N|E5eFa?a3izt$u|qhMrNtk|^J2&byRk3+LN@NlA`*P%Kvm*{(Wu7+@C(C(D~ zt<(D_lm(|tuGCvQZep5e*G#g$C0cxMk0CTi*R*8!TmJwip)7eu3`;-irPM)oMrVT+ z(FJB+qu?(3RvMZzl{SQ;DGkltR9UXKzxD=xlZ8wEa(I{1^xDyd;WtxTH8WG0zv@{sJo#4(ihGJu7Es`DzYaDx_%FZI*BnrUenIyLFF*c_^phyl5NB4*}+x$S|gOb26hz&$c~+5cSdoC$nS z)Qp7)gi>47g>6?mPN`;@7Q7Eq@SG4KW}7=msjc;Il8j2niFo~`DCOFx{pZ^Mspx&h z>)lvJP5vRVK3X<=Gg^KW{LN%{U6iwC@radIj$VlqD(gT)pK-Le47+F)CI;VfG%Ocl^?9Vqk$@rH`<3idJ1ODXsK_5)sk zdH2TsNA3MT<6wVL)#{k#Gu!rj6O&lH{+>%DKl0O1UJ>8e{=!^sF<0o7}B5L-nj>G+S@_>?o|~m4{$6@NykBp2M?#x=mteQdV})NFt>3a zi~$ODO-OCf>8)cY$yjb@ztEY6I*bULK2UCnbuM`yfR|VtEH)M++3Cy#aGJk14jO<; zsT%NzayDo{W#Zwkc2)}4eI3t9GxJWuK{19@uItn7CAH1?gCkarXtlWLfr3D~SrMQ#{yA{zdk1xR9sJkg@T75ybZUae zH4_;pAH=e`jd}i(AKD)S*3E@QppO9_cBlMHw5}`03vbBN!skiA z%9De?M2=5q3ZN$GOH4*FFmVzI?ofL}zHGP{-YW`AIN{yzRSe7(;cCwto9Z6m6Htu- zIw99{5vm0sy`xDg{Wn>p(fp6Gzb*bt5pfq>!P4s4ggd77c!5kkm(l|xh8Z&cWLCT+ zrI8@#bheTlZcr^=_&y*I=LT$&Hv>*J2eYA%)Vy<^$uX$xTxSw-phM-oMy5&D^jMTicOfWfg)yz#GdE=HQTFc75Y> zY+k=P;ta$6wPgV-RC8C)QZCXpbm@0f#(Ihn6S8ae?b_}oV7Vp$12Ax!Yg|6^Roni4 zvXtst@A{BWW!lzW;bpDVsE4YbQ?`*#IRof*h0N`k=8K=WT!irfr^HfhbS_CGuNy#& z7e*2?`KI5LnZL0w1bxFGw<$i{k97~=j%_zy)f~--zF9@@x6+~z1mI_&bZvwhyPmwK6; z45eUjeq(s98I>xXTXK0*shQ2>00a4?$Ki#~ma6fTT>aWMw1lWZ`^Cc6jc}8Nk<)So zfBjR%B7nKMqsOUx$=O8OyKAX-rAGxUH#XY5jr!;tG%&9wbw5tpBo7aL6PiR_pT4*B z@x1z zx(B5+GSn~}j(nzJdgd(Ag@{ZuqB$&$>^@g-WMaD2m-a%xQNi*~rFnb&KK-PdDxP#Z zguB|o#34-sO_JE$=k)D)5(`_Eb`VGesS}V|loB@b1`REdIihcddNVteR#M#PF)v1Gv*1jr+WuaiDKr2YSH`!wyin|3 zmoI3rQe3+u(gGh0dpn2#A4J{rhjV8{CyN=3D_WO_RbMSv0lj4Z|q}oQf_fxO?XOKgI`=9K3RM&6NGp7nUfEaxF00<{NskB zyFahd2<(JVoY0vsz@?cXiGCd*v@g6^|J2=fvWN?i*a2ySRx1fgp)@(a$*!Foy&KGO z7O_}ClNoYtEbdQMJT^O0Bdrgiinn0Z(i^omIkd8vYQF-v*CI9j!D5pjfaXk_fcf$% z;>(7y{M3lc)(tZ~lV#O&%g>XEsWWtKMS7Nv?24frTmgYF)7Z^$6nPFTAprzc4hGe) zYq4AcUlBK7jFQJ6u9ZF1;2#KKil_%=Scc;bnHl+FDnGvQAHofu05Y7QU z<2wU<2u65_G$!QL zcCfp$$S2#D@ZV%2AD_NMF*>8RYN#6cdrT@1djg5WuG-1!p<5R@soJ)x(E(A%G-DN_ z6fzXbD!uA`=!NbsnK7R*S;r_UI}$Dpk|bZ}XuwZ~4&O7X5g~HutfXY=s+c`ND4t6Q z)G$f|o)ad1ULdlgb#b>PXuS%5X)SmA#O_q*i0>ruYY*3vvq$l2iHmsU>L&xtdMbneNyDl0PdWq|Ew_v>ahLh4AIH_Q{VfKMhnNkKX0**7R#$6SVT$j6^YG7UcdnE z<0`6-(1(T~)En<`M!BBC1F!o#T-3V-N+eyI|5p$7*fT7fpM4!NSTy0M)T;ZIJ;f+f z2RWdl0l+i%9>>Q2(=yRPp{y_sGcXCB_X)`C!I81&`%hj&qO@#Mo87-l3E)b6^aA$% zstv#%uZvCc&|1kgDgdjz|5KFO1_{U|0%8HPc1FbdFD%!0_&UBDyAW>Fqs(GCq}nN` z-EDfkc}=xo16AmQFqCqp&OfNOBBen}h@=D=`^V+9;dNO@;|%htQK}30iT4sps}+wb zcGFCsvEDaFZw5H*r;5^K^TN-2IXL4E3wt5$d+&ap@an)5^?KCfqq(>@aZp;Co;_kY ztMEco!Gs<@a(vMm-@H^4)F~>5_$&@i!&49#$Oz1->n$EiV8c<*{53>L%?aR}@%q}H zQq`_V6ZiGvqjCbyl!=M2BrmpF9rT}j{$7mR{PCl6y9>wMpfp1|!)q=Vet>c(YEVdI zrXG8HuQw8@@p93CXH;SpgjMrAk7|*TMMT~50t1N%!mu7*AfTTgyp!|J|4z{9a8Q5N zuRy`m|C_J;>r`_GR(E{9hLqU%#3kEI_(UaaZVi`TAGw>&K$J7N2;C{VjA)laD(9| z-+j&8S=Tur4!jo9{1|J@jh++Xp8$tWEapT<12D|FT)k!kE}kF3|4x&z{h9sIB{izn zO(A#Wbz2$1LM*S-S0gIUc?XhG{6<_dAz3AS+>+ zw5!aQ224e$^Lb}G&ucu2MQOj<_)XU&3|Wu>Z3d)l9M^Q?`f#ng-6&XONW3LDuf+!lO2ZQ_W~;8r#R z=&H*4TFVywzRKo}LgiT&&B&WJFWbLtKNusJZ-nbX>0zaIHbplMx6_1DoD$0YXkna( zM-_$AQ!nnjHS|?@8UKNi?yih;54t*L%+59Av0|axct1c0WHvpTPSWEW+SnQ8Jgc%0 zmB5?%cDO1L@uq+}haKwWisp=?u~gzk(5cA-++8$8X2r@b?WWDow2b2qt!FAi<|EtB z!?T@X$N*&FNeVm6un-nKTvxqadtXB=-nbso;QJ5oIJkv%N!=T(;?CF6`%o_1TBVEA zrZwi6jK>P{hOLYb12z!2!%@M=@2H8AbJ5=FZF*On5bi;_t304EZjAGvxN+z64=u>fM#K%bWJSPtx7$|qrX z)|XH9+g5zdf_GZucUY!2A7uV$BHiaNks8lStW@yYSx36{29FTvrfygAcTp!kJ%bnWR~BJ(*msgqh^~3*9DLC2ohcoCuGR;e+DQ|!gz$XYfYMDmt3rc^+p#_!papyxAaw6!v?&wuXF z8^c@pI2Y3v{Ks2;Oxz28Wp1Vu?vusi#Z?rXD?Mni+pO;@t?H))JOh^Xx>C$ZFIH@b|0Fu4KqHn928 zxMP>E+lpx{kVX#D59yZ!{nIou9SeOw8VE2;Zc#i7 z#S}^#&~Tt#tl?5rRyJ#li-eeti+1_VB;M}atxzu>Egs28(Jdl!7H!b3NL8D}NGS2Xtz@cyg8oo?srZ>bJ0+*P{$*3>+zmt)S$i4pPuFic&08kJkKr#-ZcR zBtKNO2t6VwL*1+^SeGpNnPjhKqaD3IMHBO`zWpPV7b zPfW?qYKUL@wj9N<6leCvcsaIJ zy4)c$sh_`HD3_nKLkCYII*X2SR(Y=S|v5 z|1>NNEP}no85&vmhI=D(U3NQg<4`~Mtal$27lpvzi+;-m*niprirseeRtYhg z-Jh=q3f)&b3RBK8`kIX-x)6Ag$76VJ8f_XyhuCtr4i_-U5gjedzw+V<18ZGJ3k$!< z>Q52K6`Bf>*NN#4=m2okPlO?b+k^iKa~GIvLf7cO#ypEApg0Mhh2@0jt3W*;l{G zo^7jnXR`@CHprGC)o(bYs3KzCubi^W1bkq4JNDyFRtnyXc*6kL50uM?Pw{Rhhk9CX z3qKPUh$~%TGx%UER~hJM(G`&}j1#yBb&= z%ZRv|D_)!FSbowjf%+Ij@exg5bPFf}*0L6lNeQ_lh_Y)Rp#|%rH2UOT9II z*SY`9{j#00Nq)AB2?1W!#Wt|ZfOx=h+r&* zyea@-0Rz0NdsNrA`W8VQQaWSwn4-wsa`Gx+F?w@pzc8nvt=Cu(Db5_V9`j;PBLHTD z*H#nj0dFF-VFp?VdarME7f*gX%>F2DK5}KiU_npGeDo9VVseCi`8KZrv8KDMf}tT^ zJap(xvUUKX&e1uGP*{W`*`FfYeYo#WXVp~nP|sE_+HC4@1$bjzxuMPYwg&uTrUK-l zJu#tqP~m%aXB3nK3@P%SqHB6A^_vAtI}(Jr31zLFJ_GU}XBW{MV^_VN4?fzE!!y|CK-q3}kqxnaSk`9B-g-dFdIar0 z5?@a~PSGqj66vA79|gBji!gMTUq*jAr(KRSaveU*a6mNpB~@!%m`3DGemu`rtNUED zL>+=#so{dk7Fyd)1gX+D@arL+F$NJ_ssj4S`?S^20G)-4JNK>C!~9npNoDcwsBx)! zR|C6xu}Xs?RdI`?^HE{}L+)`(*6HmDQyPla#Z;g+Gwm>iq2As-EktbKqhJ~W>wnR2 zGZnS-xU+C)$Q{G)M`bJeyT=Q3nkG{P=<fiJ5%9cB@GR$2b1`og{^5ptawIuUE6+hG5u}twt}HWqeFwa zUmvl;++kWNk5lDb6$?0Jmd2`AVMhpCK&B@;>^RHF73V&(^IWV6l4y{pi9`t$XrlA2 z6=MlKsIMcRApazS|Fgm4;+V)Ly$_fbmID?sYJtk z(HK&Y3v62CGGgeprVDg!prl$JfC1Fo0!>+!*|1ch@`nI{Xa26M z`E(9|=#5Qnm%2SK^~uB?M%9HCB(KhtqA$ zZ60>+d+gDAd`#&#Gv20Pj}JU7pp|+r*h6znrbRMXyiWfF{}={H#Mvh)r7* z2g1<}dZTS&+)xeYQ~3jOtr~GV?2;q$v`_)(e4$_JBT7}!l3h$0MKzvV*3RmT8#Be) zw0%m*SqtHb(OclLyK~A`cH0p=RDNf%;-)~FCcPZB0jgK}U9oj|JjJ#XH+;~ihl}+( zmX^v)+KfAqiKiki3C@!6Y}hq{6OCS5VqXaQO!lzLs6n$6v_`mE&HLG1zcr01dzw8M znE0eOo<-G*TI2u;fsho+a8+D1dFO9{2C4hpm25EO6tr!wRp37H!ecreDCh5eoPrh? zyG*1l#%-NT*1~*g&+B>C6vA|!!#(Rf&tVy_6(SQ{q<4hIS)&OFB+Mi~8yM#6I&Ss% zpV3Es3nv{$C%X7Rm-h1=JJu2Bsp?T*h9>tB-rP8wZsUvqRsXeTwo~aq{AUrl!%AOa zU$ks=_zRYalA9#&;rJkDs{<>2+Q{1 zI$!miJ~uZ^J#Y8vke@GMxq=1P*Q_Ojb>4Wqh^klkd&W-pma?3m^Tv*m7iSns3XjJdv9Hmy0Fp>0Csv#qq`En-jZt&?R#guhijN0b9Iwf6>4 z&ICs|Xw?7RpJ`&m-H3^Ew$ZDS;?ky)@qJ3Cl(3>{BBjmeVkubR6d|A0cO?0&Zt6}# z1y7hXjMF_A3Z~p>9DHNY6Tt?f)b0J%gI+yMAG8*gyp-(j^cSFesr)Q;I-B zO=wbLqnFTwf)tf5J%lbjA%veLV;apM zb)dd3*KJqD45)o_;EfVRE3^48L1Z6e-XJgp3MazCu+jhk_T7xwmx4Eh+wr_{DJ7yD zRx4f5%RRHm%EkQ&u+ql(S65yF@=-90$2Cti4)P(AIh>4B*I9)4gwbkVlxfn3(L5kZ_;L&xv`okjjY5UA~sK11Lo)g zs7I5>T%1c!o$AHWSEGC)2da#C%+oaWem$xeBNd|Lkh6zUtV7=*)mNH5J-*tWQ;Uup z24;ka^qM8ON2)Y&irCGivQL#TYc&hqFeDketkj)^IN7w-(F;I2m*o$+7V54L@F@d* zu$utqkmessd&O|MONF9I5WQJsGSiCe!m<79Owe{?Ea>LmgG$JDYm~=*OiT7Tbgcst z7i1U_SJO_3`~{rB6Fw%JRF-E<7rPv~FG#@(G z_h#n&L%E%msM}7a+HIT~qhP=AyS3!ERkCX7fx=-lxO#-e%Y=k@=Jl>L)@(lHLMJ*b zq0O@V78bYZ&nXfRRU6l`W$geAmZT>)=Ko^R8PO(lwU?SA2HQ)2~<*A)ndc~8JCNbL@y!sL}FI(=kI*P z-vzIQq-CoaYa5({BQuOonDdD4qkl3Jq-@;~6wN%FImavv!DtazUiBU;Yl0oZt8W;mN#PfO_p`zUV0|Hci#fXWze6 zT-wSTvmmvylOQSqwy_CaEK;}3BjY|Ufh@Z0krTz|8C*mEbi|1|G7g?(H_c5*pNZ`FYA5a8&pJ+ z>S@~@1`0&HO5Uze>0qhGn;oPk0POO~J*TAQ3*0g@)yq_?X}CdctX%3++?lrxqQAt{ zZ|Roaj}*LV{#>;*d|V3&4A>4}KofGh_uvCLs4m@Ye=BH3pp02F`(LBBO2G;+k`x2|WY^UK5maAi?{2n^8ke!cE%g2_Bs$A`g(i4bUfA?a-TM-M9)EQ-+XuIb~{lxE&FWSY2?@$ z))#uRal<09ReT9DMnxs}pkMJ_6{spvW6Mw#6HV`MJGE5OIfvTm5RjZ|sU}LtJyN(V zbbGjZYC@oAxHQv#4r8vMUF+s2W3ifPO0Y_fmP))}?2F_$lt--Fkg@=WTkn%I0d8mu19qQqAcMen^WDiN~*>tsaqC% z4Iz8b1Qm0^z$eH$AHfMOC*k8?yvbJCyqQ-i2Ti8+_m&jFi*D#>1H!x3-^$M)kW_uy z@zgn<66vii0UR3a&vL8Z0LzxQqo*weOLMo+u-Si4sf0(uX?h=Rw8rB2kuU#YI5^=o znlNc_dqhQ`_%TNO1`A?pQm%`MIy1nVO39%XU4kXDcp;N+kZ7#=5E`^{bqGgt7EUX< zwa=*CuzP7Y`)2*i_2;nTF)Pk6dp|wBwSXtKJmMy_Llk_rbt3p~Mo}7jP2gf{x@X-( zPbqDY{(@Ms8V@B5xH*A{OUH3kpAzLtY#FGJS-};jn2LN_trr`~Fto%en$*Kj|HI&A z{qhPG-)=J&;Lj;$zOI>%z#3+;q5z4z@~Z`NzO(M#FNu0a!;73%LHjl|HN;3lV@^%(>1#85BSgx*!b%nF} zjnZMZG9Fd0PFz4lMECDt`_|De!vX zV^HUfLhKv4*0mb1wL|Fd`8PO{292t$`jrn1O`tj34-5!KZ#B$CMtk_3B?wmXqczL$ zt_rM;?JAEv0~dLH*d?>(6WjR7!9mwN?<3PTp8|QJi9qV=Xh)l0;m>88&X>9xs-CW* zVhQtqL4aZVQp*;s<+o=}f8X*fNvK^@rSV@FuywmWIM#==(ZTdb$@)dEFbF{O}f zMtVp=^dIP7$DyN`yU`nNNi!B>I=Yq-kNh4|F;1a&mfv^gNfmsFYEedk<$^BNhxk&Y zg&9smAImy=@r`g=T4CRNvoOi^-Cw7$PPvaIjWA@@HOqaES&xoloFN{g4 zgb3G4sW2V6YuHKHx11sGzQ=^(kIx$qFk>^Q#ITv}0xQ!Wd~oA~i1Z#AT*uqdh>tYc znj1q|Fq;%)9*iI^J(MiD;r{EaiMkJp%ZrHx>DX!*Q zVePb|iuOLRANKh+BQY_I$naVUVy2PHU5!Man>yI(ZjWJ%L5_xbO-`qJ?~Fa?*_`kg zcoza7`f!32c^4YFG$-fRJFSm$L+c&cUxI!M`qq26tQ3C?nC|W5=4jtOM4b})F=!%g zTm;rcBv5~gNwZ0EJc|_3`xF>qhfhHW^}vm7@Fj^x6GvxX8sJb3`-=EKuF@x_567~_ zT;nJoZ*66))?6tYJ<}sHTy9X{7d^xu!!I@QP8X}4PipRfLC9(Or5BNYtoZ6z-QBBU za^bIK7RBh3LJBT*1wBJIe>*%r+a`(-4`n&ld_?7loHiagdX#QF6w_1GVEB|f^tD>B zO=phAYtL$zo9Lu+Ky8AG4u?)y`xqaV_ zRrRye;3ew#iC3chbVTxux?G0z@R5-GT$PGQ{o7ef1}&tz)!PWumuamV zl-n74hjdBjdMHT%<JQyw)G1vznFJE zLhaWD{$bE|`fQ)+Ii3-R9*L9j4K!b0#B{|%0l+IhmkZhNl;Pni0?lZQqQ->IiGV zz+4F=O@EekI}`h2fy?@<-zSlrnFQCx-NlbfKZmn@&h$B|-IYEJzEn7_UV3Z)9>JW2 zpaF+RP;E3d)t`JhCCa1jB3`GXh<(}}irX%C|86=M8Sltx>ofHc^v zy|(eq4)XP=(K5igPYmHS#=v0K7a6Ro@On%LE%%`y{ichl#7q6{aE!<+qMxvdb!((# zxKQir*X$Vtzo zP7ls~+Sb->v!E}7%+zorX`cToH_D3)mn_Sa_&k^09vnF9%c9}D3Jt$woRfDXP^xed zt_0|*Z*P%K;bM(T_Hd;?2Y zu$6r9EUxhHuI{B~-+e~!<2y&twX4L;mTQCh_;o%be!6Txy$UoDBkiJ1`V3W!7G%kg z&p(G5!X>dURt2U>e44q>$}Zz23^ug!|BQoPk;F5G&7 zUAJgUF5vx5FUL>_%JtV;gobiXhY zD(bH7=!w7Ms>Qta!3+gVglrpwe;bF1FBwB^M5E{cHJ_t#=`go=oz5*hF%h0zqQ^TN>T z4#r?mV#?KxV?LycKBXPPg}IhmZ@7=#3-QgjexE^O%_z9 zgaew1Ox^UT(IkLNzNz`(l4M_a*Na=Bfyi6Wy`}v6znK)uaADGSl=8oZBo#(X_%_`` zkW;+w6{_1#oy_>Co1wRI%9LC9iwF6e9cZU``h_b1V)GF`mQB0X19tF~Nx&6d8UqQ9d}&`h|% zWS8H$N=^BPA*ZJW$lh--q6lmUOfM`}t2{uj`&(?Z=c6LW>I{<+S-yOUa{l<^d|TFc zj=Y2m=3wA-WZQS9?@XJULpQ{rC8@z`6c-s^IatM<^4MZDqF1Frlu5X|J3i4;Bp)Qv z>?EZ!-rZNj?95c)cItid(j`}&R4Bg~bsTVRt9GOBmiqLUT~v_lh+7=w>g&{f?z?2< zRyBk!Wqd^25vGNLwUqp2v#vv2UC$P$PhhKFgJQf2Y|BL`8Z}eMRHCxJd?#A`sh$@4 zrthVzcpLk#SjXKofipaE78`I^>*(hvI_2-_%ANWAY*c=2y>Qe+w3pt$9M|iY4O#5% z#Vp-Q!)969<)KgGs$)a^mcjDxBkF9|H(h_=q<#~u&7?8jBmMTKY1=D#e@i=U$S}}4 zPHEcHMgu2s9>$H!ha)weKs=YZECwD&J@--cQW~wY8uq29^c0wcrI(Mst$H#Lo6FUg zOS+lJhxJIgIye-Xf?(~>!j~#*o09IIMo#Q$)mlHwK-r=oPbO7OhTYYOuzX2GP^YU- zypzX`3_gFL!iqpMREtA)N|gZv`cBgbdi^I#SwL;|N{2;rh0Ax$au12|s`p#8>W&=m zTu(OlDg9cY+^9QaQ;gp%$t2pnK`PWmKokDs6_U96^)Y$3+WTp8#Up%`StR&-yf_CH z()r-hr$pC$(Mi}d_}tY8eLAA>^U83*ZLkJFwbhgb{m=G*qkKAg@`f2t>Ktz?W$dg&_Tkz~sG_Md`~lDcGl@Yf}T-;$X*x8KRX)T}c%UZhYqhmyxF zvZ|!B^A{X;XUi;ZStWJlhw>n~;L2P&GKZb!rNWw67d|xoT$LJ({UOW8IJxeqRp6$kWjF9+pqV}$p;VUlfiWc^HVZ) zC(Axcz67k}Fjjhr5M^T{N9iXb+-e0c*Q(X8?hnd4Va|5)%G%^Q$Os=LY;^dm z`Ed&tBtNdJM5Dh)zZ!ml+}&ZJW69n`)Oc0Rl*Z!4~9l- z{)#1Td~Zp*gvr_}IN9HuA?{l*PMfxP@ux8_*E5!lKRLGzmYZz-z6-WXg8!W>K1ar5 zC1whLAL$3Hz-piGk%BT`)m^!g@0hvk9a`U@Hc!6O0Gg^=>q24EBxrRbj!+w;Xu4Jb zxqzd3XhdPg5`n>h;i_rw?DrYU` zR>G>q8wl3wmEQEqbv7_(5x9u(YD{I~x%hK%NTFTwBiF;g43U-+ib%F;uu(RjcN@$j zr`EJkevHWK_=Aab8A`747p5*V0d`WE?L;T$(kpouvV&hv=c#CSF@L)&^bZ3&U*}Xc zx~pP5!+zZKm^)L1z}yZHjMYH=F=C35(vTD$b6~_N+#~XL?U}~qe(L1O!(*1}o^~v) zf$DQS1IUG2_UF;`LC4{E${Kde_L2vc$eLn-$ce+=xMF8@v$pnjreamf(7?by3{$S* z2Zby0i5I3np0KTqS=R(tyJYw%_A0b6e#>bq_=;{RCOPY{^oOwi#SMYx`f6V_5&~a8 zBCiafoeQEQGK>nkJRzEyhS3F&-yS(h%MZkqL~2_YYbO1LC249}#V663=#(=ZA4RgQ z>15Hwv8aSx9b5#$bg7arU#4y|QaAvTd)f&ZSD_u<-5y4%kR9jox8oTKgm?L|Hks+Q znP4PL#P|5`;CnM9ZLq4M!xOPYUMPSYl;O%g58AzQxNr1;TY3Q1CbBV*g_VqXZI;F~ zqgym0%dU_F-k-;pj97j!2UQ_bI?5!MFUXm|0+N%Er9Tt-TeU&_Jvi@7H_eU8Ql zwCRzK`jlcTYxjBUe;83mmx4#&tP;|7Ykyj+9zA}MD6L+{AMjjv z7eeqc=k$O361zGYFhL7UACV5N7cAi98$S0AUC=Y2cBrQf?@`(okBrJ7JK~vj2dAwR z;;C7(?NUo02Kh4;_CAny?DNP@%d&K<0#m&3;21R#v{1t&N?pPz{LokCh|&+V>4?jX zNB~t3`2q$Du{c3dA(_A1sfb!Ylbo|*mb=n*zYg`S#Yc~XbY%y{SP)I@?w=V+g(IZHp#TBD%S5q^3CpQng(Z z0#U;6Baya^3D=ldne|`DmeSvZlaowyEpm0k`O8zCS(gkt-&TKRNb2070aU-|nz{jKIc|DP?wilaYU`V?6>9%kfQPU-!e(QZF z?4}wL29VaZqXuCtJ}1_`81eq%;dm{i?qjj_b{RVsBBu*ez^a_Lf#WnaY$t<3 zD!-d6N27v%^SKWAn|;s+Q_3v$SMyZ2cUMLv{;H;`Ldf|e!l^>(RA=g;lW(kg4osPVx!SSxMxnDYJUB_Yy9M4Bnnu}O5F$mn+jH{2zQ8X6uj>TnbA*0{j*izrV&dP= z@nA!tSz9Y*T~E`#nRbrF{&w~1{)jba$#9h=%ZGB>f*lcD2?@<-n4sg*%?4FQdu#q!=bR(0Fu{R~DRc{^b7Y!$q45ncZ%NOkMvi^P_ugqCn){b-W>e2Ecor&b3c$v zJUjDj-?MSKm1L(=>m63di;R=^pIuz|;F_`1C39lmrYz7~Ksm~Lvdp~jaue8VD~BiG zM8xf^mO8iLTeP#&*xG}B|6RyHV69!wJ*K3--dE^t#_OM6ax8tesx%Y`gD)(o{=dDw z`kHm=BE`97I7D$~bnH14mT|_e;9*VqQdNYLiu|E4Y)zSudUO%3amwZT&5Mq=Imf#m z*NLXy_Es>F{|1QnoDws zk=6r|`a%Pbj8@bg(E8N{R1^x1G;4GZ)>W+8zf>kNbFm0nbfz=dC#;TY`?pptLKeOGR0_!8RYY9)(lQu$V2 zDCKD4WIX(owv=?O_?gCC=3D)4#5vBZ@(%mjaj9?nIE4uxUyQu z)*1Y5GgHheET_e?$Vu@wVK@sR98IO8b9I3J0GWtLW8sP^yzpZ5v423E7sq?i7Y4e1 zC-a{;_ilvMnQ+g?#4dV&#_(CjkffUeGrB_ON#~KsPge-Q^2;^Bed=WPv19U&MsqrU zYcXQc2;73Vi(qxCuzp}c59uT&i6Cc-U|h1nn4t`?_#2s;kRxM_=L{+)ThAUIsPq@k z{!Fr?mT{kXCN?uzi0Sk~PWL>0@HO2x1&NmrmS_cL=;da@2ga@jSy)m6zf)MhXL!q}zu;(%Yv z+82m(_tckURNE?#nEn*6EgY9A2Upb{=}IV;vB=Z)NCN;;OwHJ~4_ zuKH6R-Z5;o;`YUv1wxB@3Xe||;$x9c8CV=0ZX!5z_)eCVw4U_>d?V|wV2ejz%w^Z3 z-ebIlne3U4-5wQ!p_5Ymn}X)ELPq^nEIhr0vLM^4X<2T*S2BhC1e(@1c!`$PN^G=vZJM)v;QU9ED!1{3fbq3y;+w z(LVQnF;lsZStpNc->d>{QR)%CWU z-WTtK+T=7*zw7}5Qk|TJG~)nvPpnQY_6xrQq>1Y>B`U^>|0Q zM7Fuq7;oyV=rQH!1^z@K=p;qlER)}QD}_w15))0w0kAv!vF?KtndN7}_3p-UwrL(; za&-f<^;>pqjBix3JK2e4`bf&^--;qI0{cnfe2KEe*(@e?=IPSn*n#nvNaOl~*RmPl zv1zYvCuKFU5f2aips;rA-y(~yYbsR;J>m$zf=?r8*jMTb7BI^=CTA+d6|5?K|9%KY zY&RH1S&tenLApG^)TFx9((CU4RX7<>8&oKAjrgg$<@@J{RbzJxdp<4JE^kzfih+IB zf_VFvZ4KhP$_~inc9Q$d$8fe!`21w*%ot1naf{JalKF2be>75sRsc#llHjf$qhBzP zS9-vjZMg{NMw3}c&dBHpK+g{7$aqHC9_RhK;uhY}U^w#aC-Zv;`Dc|-e4+Po`j7C3R$RY+FVy|Zb|f;N@5I;!q44C)cluz}&kNhST=AC2BFUhf=-dM?;%sv{Pq_?|JD zd?ahdJtnFgwc=RPWa*HanZciGR|s4Q!rcZQ$Mfouh7mu{Z&(I|(~aDqo9DGX7_GRC zwZ6A^eMorqMZR9(bZEW1Vo7w1PnB9~k^hSGg^%>`o049= zCAvu>iFA5J6Q7WVf2XB{;gn&Oi^(7l5FxsVTzY!^7Y=<@q7i{N-T&gmV7RHBcxxi8 zZWJs_&IQE2mq9{P-Qfz6Rhq-`;3^eHdMa}`pf3;IlJ|>9s;*Fh!JttcTwI=-_eG06 z{W0-R_U~`+7-T#fGEaC=X1HK_%_nu)j8Uw!0H?C4r$io&**%wB#%i}^qqRazzseIl zmi^GryuUY2qU2lgvbR%a@*J4@xGKAVA~hd5!VD2D0Eg>zJ?~Y8h@_~5_v^^4V z5^|z@gpUBD5As$Zi2En8rr@>APQhU?_r(@Nj}~YL>7CyMG}5h^kja94=R1@oBAAl! z?Rx)KNOkuifJb^%VJz}*@#6o(`3VEVWkp;iBe=0TF=f}eQ}#!;n&UQf)oH2pgF!?% zhrb}>){5GBxJt9tX3&-SdrFh&^0Ldp|1ex+3+lbE)ps@Si*x4Z8E=8@uvE#Pa*iZs zG-rkL!9n!e;VJ;^VtIRN_`%oY zQBUw496g-5AZ_||k`mPl*_Q;Uy8VRxx_h7DY+~2J0^`!=WSq}Jcc0_;ZIagoVR+`G zhh~A6pK7j-h~ZpPu9!PDv=|?kbtQlH%^m*tXRqkb*SkVALR^_cn7a2tx5~BV`i^aU zEPi-Kx*$ZXD3E7>xsLwZx%5yq?~saSJ04i6YfV)e_#Cjmbdmyrgrrm$0&{rpEo7`# z?KG|jO+Sk}XkXfIg3OeRJ4nU5x9U;KmYGtlT$NpiOC=4%mizPJ)Ra_<_B?eKsv{%YC||Mcl`8^UHp6&t z26Ue>ePQ{B&xP;LE&zhmbjpAHhO%3}-tTzUd0a!>*jOpab8^^)`{f6;ui3?0ZLSe+ zva`@fXeM`k`B@3kGP4e_*yA&uwc^)-SLwtR%H}@L&c!|Dp#7od27YywCm-!eC6b;~ zyd7>y$l-cXiGgB6r9oNOii%%fu{Mqf0Y=S7O`sFOg3f=K%R@gA_5@1)VGt+A{s3oj`JgObQTdCO z4(g^8c?2TXIz*{?-{Uip>Jr z#QLQe`KPpwQ-aT=^q~u(4S-?S>1q33l;)1F0DCm1vsvv*9(a`1dLz<)-K<%;?0k`9 zJcVhy#@)emNVT9Z<9deLJM-Ky0pu_>gaoVt&%@Gog-+03`9@b}-03}C_3hHeyOE`fwQsZLrT<6pUz$2~(44~RZ%X`vrT%$!9_0I9G@ex`3&Vnv0*hG;gP(e0m`_U-~Z*sthled$JBt4!#h*7M=a_= z`45{sbN%~(M}(WD-3{4*HhSh;H1K_2%g<6k(z}Hlf+bH*`LkbE%KWx@+WTW3`o)C; zf-D>m_Ay-y(X`#sN&LNam?OOlsGhUWTJGz87#;(ioh|x8eEk7EQO!o}{gqSJk_m**3wllCv&(K=SE z-T#BX@oJshVn9ORTUj<5Ejguw@vevW6aDx=LKSRVVVYG6vt|2#)CyN@8 z#JPrjhA-D9rwWcorat#q!#a0X_V|`r_dYCEIlaL*l1cG-9Wgra8Yx>M|Boh@B$^|b zD_y%oDVz6IJ_PrNllgAI_nSeT{jzmpeQrS)^rOiFMcj-e+j&I*LPZ*l^+Qo~%37aE zJ77+eL}*Q$@>6#2AN%98r(wA>|1W$;$j@bnNo~AX0x!5N;9Njxx@qE}ke#EMHE<+oXS~?_XT9!N z;k(rC+T(4zBFMAtb%Vu<&(8oPwR#;O)1-Jl7dpm+IB`Ag(h25G>ul}yTjwd+L3V7GPqxH2L;=go+H`$KI9<{pFX&jm1m zAhP6ihuaL86N*1S;n!*DIl8c|wM#NOwp3?(C>vhK(W787E~LNPd!|F5h7NbJH}sKd zxsMP%R762!S5DKz9557aTQb+}TJ9@(Dn^aN|1kV#)Ljmm`mitV7FR!Rwjsl2**`^h zLr=c0ZltbC>5K+Fvwr_D`G;n`IZ1I)tkMX>oVyg8;8%#m4rmKsj}%pRfC2d1KZnV4 z^_Xu(P*L^(|ECYJGfLIA4-#=p%r#w+z>$Moq{Z!%_w_1&R)A-uBeMUw1HP2Mt`*Vvvz8lYsH)>A7zEx?<%C!16@LM`J9A{L$*4$v-!X9 z1`I=}Ap-nd#7FDsXANwGFEIbY?x@PiwPQh=Aq$uloHRnChUjK{_t$L2xXp4_vJufe`;34lWDOA(*}!rCuUe(+2{xX>U7?^=Vr+P`Zsg+h)ypS z2JRpY7Iq(iMD6~U`TBqRkEfI$$?Fs?Ivo%E#twEUOPi6{N6L{`C2iR|Zu$s01n7D7 zyMixT!0b>O0PbL4itUb!z)a*whG~5UVxz#EdRh$AhM@OQ#Y137U>e3eWdS>KBfqh5oRX_E8ybD}MFDpg2vthn&iyumRdjdwNbx zr?Ms8cpXYRq3E!~?n54f8`L+`fKaKR&5WRnV-9apb=lZ%Y?>Eyhe6@ks!oboXOqXygJ(r!;dF<-+)H&4AL6SLSd&v%S))718wJyFm7B_Mt0*-Exg~lJ7dylC>gSAp zR+ozHy!&kmk=2!!lyGS!Rv))z*v=1maZu?$BxKRm_(YP2UAtZLTp>ZYsx2fXoN`5RqT*Nf=AF2GIOU%9McjJb&)K5Y_az@Quoz$NOtl(MA=+ZX zt#+@}cdIJWXJF9y?bP1VM57`s00S;2K=8COLi!bsuYUvZWTui6r^z=pdWwKyVT$m+ zyR*5cl3%lYB?sLo{egmWJ9i_d|Gms5VmkxTH zkYA^k`Vl%*^q`4BO$sIo8ZD&}c0Z+z3I^-~AUl9PlXn7!izQp>;s^HH>73Dg1P{s) zx0~3KoG&b7M8^tkPK062xPf?hM)4`KLv>uG^!` z9w>URyy+*7C4R@#He_IjNszQCU0>Hq2~I4nM_Pitf z?qb4rzy%|;0v=?)p?i1YA}j|2~8a(2t2h_;X;M(jN~Yrjn$EdP?dTW z{XGQGM4lzH=~LPqEmo&Qp;qLl+?Zn+ee=9<5?85{`5_4~0v5G?0fgAc=a#e>i?!Mt zgSJxdBqDBgkaR8A7pzLx=FeO z;KQPx>G}N1e1UX=z2-~uEs|~tm2^{@sZ+S}Mi@Ut-YF&TKm^B?TvJSX8<5j41)mFV z^6hSGA1kw2s3z#OFREadT%7v{6AwYzH6^VmMp>zK*gcN&i zzq+`){%PFo!E}WL4+%vPKE$UkScNpdjc)l=J*!o!_cV-^kOs`V^m5798;k|d!?ght z3)2=7bIa=09#YzVrkG>iP+fw@U z35W5iW_)z*f!j9Sjg-O{Us8-HUkT+ke&I%D&HDR!=;t(8BLxz38H? zCR}e)gzt>~pB?rasmRYR5pCpq7iABct+JwKcMsFV#(rNv`H4Y$Y>%uzUuP4FESYb( zG6#Pkkub0p>cO#6;ke40MAceyVI6@=K_$BGIiC9kW$W}NUZ;T+npVXLLyugjWC0qW zmhQ@pj+H9OJ%JVqF~TU+`Gu@=rdkcQllSJH3alr& z2F7zHOec>9A?PSazYRB&O9P}mJN24t?{7$9v!p#WMX!H;Km-$PD> zHZ*G%SnM71#0bh<8{a9t#0;7tQt=3zO0LwR=w$##p9Q`u>LDR(--pEFbEukyNioMa z6$i%mcKC-Iynn#Sgo*O*UC{WDCs#HsFDp^DZp<&j`Sd99;ha$}gHl8otb z(M#kNJ2)DUDEpiJ0kFM9cIy_#YN7C!fDK>Ay)k5^lhoUUrKoKr&a!K${LL6EAxvN=4HisYa{9$dUb2C9j!e5(ywn<)t*flNv0OT9Ftj{rKGFr zj_@l0lN6W0!oqnnwBbK;KGm*E;p*q=NjkQX8iuTUVp67{V5eWItb=~Q7byi+6!qvQ z2uLJqE>Y&)vp&V^L_wJKjMuvs=NNi)u28sFvMMsBe*o*G)CTcLy0j?tjr1zf1au0^*W2yrWK+;ved3uu(la&+p`O#UIexkLuMH>NtIXR)tt;=7y_M-{{QF3ICc_bT*0 zI=Dpd%ZL~F7QfjA0P{QNzBtT2EiPJUkPY}E$2aTIS8V*~1CRQ^vKPluFPJipK20d; z(S)h7z7bOC0B|ZG);SM8igd13|4`_M-?sq5`7c=upG z+#HYd=LXhea6pmRm18zTm7%l=Q+3kVYtS=@DGF0<;|0w$AD)rh_>=j{Ekt{Xz*@> zNTzuKZ6&%*Mk^As_pCJe9U=gfoIK>XK&RTSvRyf4n{jd_QF4vFIv}@DY1e(c&d4xR zi)0WstZE=1d+-44xC?GcVaziC{2eu{B6GyEzt%>1O57NscH(V{WpmY&jB3ONwONV~ zJw*O+ke20btifmazzRz?xjyO|1DE93*=Fl{8EbQM4t}XYc4Tu7Vy%{zu2+TDdXlg8 zOnl|3*pH#1Du_lb;bvcL+OA;9bXY@+r0avGx`i$dQ$(d0n^=+CAvrM^8H=uQX@Cq{ zxJUd+yQqf67uE_1osixg-(YiX4uzAp?|#*vVQ zomVfHhop!ZW%h-zTri-Y<(RYjTw}^j-U!VwEHk#~kT(CNLDZ7t7<;&t=DU~FM$H8( z1E3%U6116+`Dv0cKVFe6%~6mIorExIjQCuL2b9`Z01t= zTK7Pwy3)jW5+9;9ZS=V`w^$Q@h#F-^bO=MoG@PJt?e6X)7Upx#r*!Hfh%b~k!Xz`p zR#vTiXoX3mznR_Hm)u3$jIxEOe9lW)tnF?xAR`VxN^ti-gF2c-T5I zA~l1vp{tL23N@?63v-MX2Pw1Z);)<_m^0dFg1|tA%@JUOC1lR2w5h=ffJ&rVW^5<+ z+L(ed}5PlQiI!-{EM>=ht?afOkMA{0NTHE{EW(W9oxrT=#~qad>MWoK zw)zT3#RxES?s*{@Q8*L?lF;prc;zJIr#yo(c;%zjc=++lGI4aT+NZ#BxzKp#`nUoo z5s`#^F?01@LLn{=B4#dd-b~5!=GNqeU#dG_%I_Vf2E}tE%$y&H#SmV2kn^4OTarR( z9x)l5a`qbda*;_5;Z9sR#@-V;jbwX!eAE^6ma-lqS-R-G2p6E9Rv{ITwaQ% z8{hO&eWR zbB=M}_wS3?`l8vl2KtA)JZUZypEC^#RT>fd8-802o&!K*J9cTd!wj=Z_@?S`&>L#S zFCjtpi<)!F4@nl#ZS!%Hz?EnSUWQw@)jF2ZV}lxJMZkcl`K*ORw9iNbfuxBR@kZ>t z`P|&+)o*I|Or1Ri^l<>=b5qK5SJ_+KxC6!qphnBs5L-SsSNt9_(};b=e3@TJWJ|9I;V7uWjb=E%GM zD(BHn3odGa?WZ$0w-uYz5A8en%rq~o8amv+tVueL{$^{BRE z5tD;e&RwNDor=%kYVDCtg*C^s!43n88xuT2$GT1GPOaJvLOOZ$8B#3~%nt@?a6&E( zf(;_t=eupOr7WX++IBNB;{pBY>6_xYv4+!fK(6A8rF2a%e_Y;3LQ0O~<4lnP?zIm~ zB;iZ`r~j9mR$!c1*ui+dmx(CNubDR{jqWic*Cyi8nlZ1GF6x*2qdrG-^FYx-e7u^Z z@wk&Rzj!BR&Y@e<)g%rWkWv!y3ok{U41U*BzlE7GBc1p~=Sk!-+Vnkjdz&|`TLlq* z8DIKGSB>mV52+{g;ASh!hw8XbTJ2Z;k%NAAwfQ_|536^0Qy0?t3a9SK69+>_9$26o z-7RJsm#urO42gWr0k8U{*Y~N=(_u43Bb(aFCeAR!;ZVLDawqkI23PvRc%}7-A;h{69onU-7XpEhXp0OVQW2n>?L1D;#Q)^zrQngVZ#Y*y21nYxUf1Rd!5#0 zFtY@)TU)?=OSkT$WJZ=z8ErtX1UTUEuBKy;msMKI^P7Cr9j{%ajB0BFCX48copipI zW)6ZYM)Ej|3|RDUk12+EcLei0dEM8X^bxM;I}R){c(tnj`e3P?xAy*~Uha=2cV2V~ zwl9>2XUoBHpQ`OFQ<^8=+3xr%pi#4mBRZx$U^O%1y}o_gI8mizIXYke2%nInc;~`& zY(pUI2>T)T|18k|>oUoB19;hHv{yJ!x<(gYE9AW!*&`z>5M`|0Z(JT?%HKap@j;;` z?AkLOh`!?@tdKia=jBb}u96y3BvCq5o>+Vktrza8Rn8r5xsRvqO+T1P_=f+pl%@Z; znwRi&PCR@vFlb>ySL3u8b@X(CR#PIX7$qf#j^xe^pA=aK3nUp}5 zf8m&kXotS5yW_JWgY|Qz9XM(4;?XoK_?qM^YrOK^u|R^`qgOdYTM3#X`e935aOpoP zV=VqNa6>5P*L>Gmp&v^GidC`i-RS0U;>)sLrk8ik0Ech$K&0?S-t&kc zFZIX9#))wiOv^gawYMs)D_y6;VSGiYTw^<0xmq$5S+{4VVsG=!$>XYgPmqWcW($@* zt27G5e}9c&58=VVP!&@1p9zwVR5d>7q@l}@(!3iKNsHQI-bKo+RR zB9*8TTWf-I=nF|(68WL5`&#qXRJe4OALPAZ?HV`%j=(WW{w^hqr)*i!$Cusi@{&9~ z3!|@o+*i##i=L+WTIXc4X%Z0s6*s)T1(;Y-BGMU5-Ot`IOi%W>snf!zIOpqGP{!~p zBO6ox;KbKo@~c;YMg6Z@J#e86tHND@hcankFz8#~I&@UCJmLb9P%Q=yIWr5qSAHwt zAF|PoKkuqJnQdtL2Al;zr|_lTd_pRpkcs&I#~FJgp8)8i;Y`N{a;x)TmxO~mgOvmh zg#$QG?jzRtY5IWA#w)w`vdt&lE$mP>-LD;czdhuDLioeR$7piMA>ueLcIM1077_&N zZipdC(D?uh&WVZ*xJUtOs1vvSx&Ze`5=mA`$=V6S^rPH(6lvV>w^fcElYezf+VM^0 z_9A>AzX*4^P-QX#!(rN4$j$>f#zSz>pKd4jgIIsmakrpzPHb!e%W-js+v4nvK6UWp zJLOLDd`b_pxWCoW2!OiyzDfPq%PboLnFI6i z&VZ4rgDAt3<-vrj@L{UmvzbbF4HV{6twX%iC_B*}c1RF+8R(MIbVA_h2`^kkdSJ=D zQ6$eyV&Zd(-T%8%1&q%fUKGCR;53|NHIa_kF(^_MQspfd|IE@OV1tM=8l6xG2*W5` zCt{_F5^+l87Mc;;y1)&--CNr(G*S`ex2(O8BvAW;(b#&q5z4R9Q1V67 z#4yaM!)QVQAF>a*BE+-X9_RGihVz3y)3WV^45nITGUY~1Z@YW^;WPZIp^1UTX?xCajkc7cjNyk%q1C~6@huU^fGPV=OB z?E5GOeN@ELg7#sH;?2#3p>c2R)Dc2`rz;O2n~!0wVcqk&T1WdG|B%@zDh8L%aQ1Rh z2>ewaUoSNoDZ??$S5X|qrIiW6nq*j67=yNaWFcv7s8?T?qmYZ179JSd4T1s-tv|?L z#X^a;;yrTm(!b&#G9e~cGQmNsYZ2YYcGOVo`pk#ht(1VaS|JEdF>qoh0%%) zM(seCOEXBONjlu)c=hZ+I!#jSBfS*nQuLs1mcQw&ZI5|ASDC3LwK}0>n;jV9HbBYH z*;K4Mv|!^ynUztJT&NAROqh<7(TQsXbTOa-a9F%Tat2j{coQUHm?-+dxyX8aK&gbI z4%GRRpz3zS7sTRAXR1l^>|>PXR-a7fCQgoow`@wUEj?ZGd9G0Tyq{mI=f$*n+~)|> zqgSgmW(^4~9&=L{&l-F+GVv0^l0N}Dtii3fvTa4ZpHY{?MJM!B5%cI>^K)bDl-Pj*~&8r)aH|@87e8OAWxz{fjGwEt>z`a$CQ8u4a3Wa=L>xzd|h zrwX=lK#dnMaH8$vHr~eXBgKcGd9%I>s2#O2Q=_&(t-LI;!xutkhBzUQiJ@Txn87LR zIIF7J@-@n)v98Xi0PPcP(CH<5JD7Z;{Of0$#*t$wmH@+Swl4XTLnRu|HFqX97=j!f=)fLD;R`Z8T%rLNc=1Z`Vd3SmO6fGe!;} zXpI=B*m&84-%h_v#y1C`up)>jz$d?Qbz}u_z&{=iaQuOkqGdKCcQj~W`iCsi^7h-$ z4Xr~N_yZ+w+lUjK&m5cZ>Bb3)`x9a;oPTC=gc@_ zDTOce^$)E<6}eDxlFs*xrxoJ$3p<;7L|GilJ0EHkHd?RGhR{zNiQ>z%79uf8__f-S zS%U0u+ziNgef^0^X3AIMoaedXyMId=h0Ba^=iO+ZS0%AskhEA$B305rv}s7JZA0O_ zfU3$^fn2R)mt$yAw^4k}B6X`QSPM4Xg3h>0BfIR=wPgOa!CTStLD$pYc6l!>r8Y7i z4K@)Ps5bSC7}fyrF*P9<3g6ZzKOn!o%LJtWf}SA$G40GxL4b^u%hoyDM1C5r$hYMy zWhY&?&sE7>{NDioh>3i^Q?t!t4$Dgxjcp(fM7vd6$Qe72jskScAt)O8m2vlEE+8L~ zWsfNMLp{6k>hv(@x}^1B@*lD-!~UTE1lot>UX4Y8JcF z%6O7x6(E+S?}MKXsI!#f9iUj>0A=XJT@`IGhqX3umz;iPJO4m^X4-NyPVFBu&IV=9 z(OZ#Zf25BqT&JGUq#!Im!IO16JeG_Wxl#vSwy`!EkP_E#&2Qbm`a<@Ue9QJ$D(+uc zRzL_(n%LjcmrcxVj)Yg|%5%0+t5hna_fFZi2j4yf^;o3tPMoObq4r(vbXofVVq?R; z%s-s|gt4NA3*0JsT*b)*5@pV3Q;L4njA%d2v+$1K^N}Ft8VC%mQiv~MGeRJN7IauvUMAjl2?YM zGl?f>FTe?>;C)&S$<}Gg;SV~v`NK^<>rk&qdafg2(~vL68eK% z^u_(_UxMD9z9pY^7C}EV_3N8>Ox-q>RtpC4Gj|F=eqw{jk?p+NEKdp%ET2;4qidz5 zUuWXP%;Jxk=mQ&!IFOXBUSCSV_c3OSw?zCQYDeer<8+9T2vAcOH`;PAnz)4?60Bc0 zlX+1>%?y8Q^?Ln5g~?N|GFGvO84q`gJ@o;5&dd@ zUh0oglp0k%A?C;#Dk>SLD~h{_Prv-Yo1Br8wl5vdB02Q9FD1i(^e*|x_A)+>luM1( z=9F(sJ8r;x>s|@59rZ?Y)s8OlF206r2Sh_b;}gS+3~+Jq57e)-U#2TBzYh2yU`v_L z-GH?x1_K9~Z+=)dt&A{}N7Dqx+rS*^kx-Z;h}#0VmZo&Cf%)c8FF%T#W1tf&qpeU=$fu&V$9Q>#yXi(mVAr3#um)Yxb5i@B&E5f z;qFE{(%F_TT7pA1maoOEVWrdb9*Xz#&Kq&Vy6f-}djPuCLm46~i7uO8ce~7cFcafq zR-K!*u~x_aZsFhRLv~}nnNgX-?dl&gfs2Zz-UoQvS97G9nDq5OWT-2%{S_+moOfim z2kW=xVf!Cq9B18(HYziHveX07l1gEvQQc^e1qav ze2=C{o$tb{0HVe$8zoQRolY|}&m|??4 z$}%H3wtUBDi|cD}Q57`?Jb=`h6_7yGILKB7z#9(e+_`TT>!=*wp=awy!Ff_Xp|u7n-3KO^90H?BFA3F}edn&p9X zavU!w4LxfFVjy%o?~4m1Bj3OS^WsEc+lr@K4OFv{R`I5R5;Q$1jxl})`=MczLu zoJR_wlTx%oYKB+c_TQnDYFJ*I58o%0*JoQwa{c5qa5$?RD- zMa_9m&ofW9*KjJcn8gL(Ssj!Gx)e?^akOl!7G^ly^wjzGg8fLc&vE9A8w|B#uKsD= z8?hN8-b}ILWQm^5a63;XVQKBGW?~tOakc(}exp@pXzrBEP4*Sc&-%Cs=V)2A3tKfR zV=%o+-7q3jY7+ zZeVd_^Y^3;HhuXfxm+`zrFtJ#FB59X7Q*)TBkUI;gyAHGML*0Q^%131`FI@dmEU>i z^XAK|P~5kZFNsGdg7^A%dp$7Y7R0xAlUtv~KKNM@%%SYwGB6wGI{Wk?U(>d6ik=36 z#!H|*Q_rGyqEH4(4lSaVH6QxTMEuOw7t5H><9T|o3CU^8 z-gp?Z0n2 zF}tZnjJ=~EL*7v*2jucMBpJoR@XK%%;)?raF-=}sjK;Q~$u>bLMXShGs(S>!)vHfF z6Y5xB=374%g-$4tBt_({OK^*)%N0*BA$?R{4VPzGyzC447HXANUlZwq)D=qGpdGKN z4MU>y!LOx+6X-{g5S9SFp3U3CeasQAt1r44q+&p@nVd=iDT#!|=PqgKLdkto;T1il7ba+`o zEmMpMN5}NKatD^K&4oQ+UTG51cAV&efT6u~$V{#s_Tt>QFb65#0$uNu%DBr%nZ-<3 zvOLveAs>jRP|~POhB|e?3ZKd6Q&58Yn-t_vazChsu)0-~66Xf@q=e&~{e1_$4b#o) zg<6Hh*0gZl5+Zz6BZe8=!MvIvSYxAHJc!$n5A^B&U!77G^$Af&ib~1gu~Zx|lhrx^ zV~tfW?KPC=tL=|=;pGxCPLd$t!|5*&FBQQ7`)nKa)@9ZCb zPENrs5)zEXH{ICE;A_vO&_gL9b2Ct!4sNN@A7fxL!H?ikR!g!e@yb_%8`hpuO48Dq zjHeLDQ|RInGFtpFVK+CkXWxeOhs1Ciy}kHmqaRVHF4|q(wJQR?!hcu!(<9en z>J-vis$Tx*R3tb*g3OHv0p!_EkCIgQ_%>J4pu)=DKM?9Nww@pX^;(1W=rl)f7{LXi zG9_GkjNmGd&VDawB4Q_Ag;qQFWo!^;l7wV!V!o;79;G@TWwy)Ye^HB-qNh6lG2`8* z!U+x>CmvxN(FMzcacA64kT>A;TDIX?%g6IahmN?%1HfZd!yM2X%53@lZDzk4_uP6G zcBU&_t-gsW|7vnys<5Fk`pzNL5Ip)bdz+SsL>}bPyGo{7`BEH=?~=EQ5^4y7o}&#M z{&_!cNBZ+@q{pSy^A}JuECSIbMZpdFfB6z#}S%5@CVLAGe?ZK;zfG$p8ty+U$(+4rp24Run#>xloSJn z@(*tsZMf6ZPQ&_>`u&oSq=5ddFy1pTLl7O*$z1Ej>0(Q?Shl6Rbd{02bO9fqdJsv6 z|Ec?0EO1T?mZC8r^}A7Mt+^0uX|jfuc2CDm(9?VX!D4eP)FHOy!F^@6#$PONx7?U4 z*bFs)dpxYn%eyKy@Dw9v7ii}5M&u|Rrp4|vh)^rojVW>mA6hJ$@TP$?KH0UP{qHu{$IRSer# zex8K`fmsqB^3Ct$va1j2+zVs>i*$kX5#Q?TlCKD3B|_itF$WyrQ7kn4jtn(EJYuSXyiOgF?3xP zM|9tF*I65bKklhpU439kz3Dfl*>U0hWy-C((jAHSiF&Y+so}isU@!woo=;c^$WC5O z&iPnpOq8qB8PEeFpm775_5~CtdsR9ZdHZIitw}uvETldYoMnL@4x1>#0YpN$Nd&1N zb#>MDXC!Bj)|>&K?s|*K=PO zhf!(kwWCB9Iw&|HnncN*dMx--zb&QymUw4RUHN=E`J%LW6G&>4t3lk<`Dhh2!Y>b3 zHYBfYG<`cMIK{pDW#~B>>7k)-aBnD8tUode+L*d@YY=dU4luB)W*>~16gP($KaCa# z$Me>q6L3hAACpfPl95z@znQ&!!>ai~*fE#n?m z7L|UB>>9S|clO{nPsZ)+v9CdCB zFE%PFSOo@~-`Cq-%F8kwt0{RsDNFae#L_63=65kEuGJ-4Ke?;4hdo*sIF@@Uic?_z zico5AucU!>O)fDTNGJ(5u$D~wEAqvp&|!Oxk;xpRbV=nYw1C^BCWs;3|EKDErug6= zGCH10VE8w5I|qD}mEG+t5?Pb(;ouN5EPfVwh97+n1?-L$qu3IegoOfiB2z(Xy4ZLy zH13j?G}zAh$@m7e@8#xiGA1rLzoUEoO|18PXu5k?aNkH%rn$p4NBk&9TgTq4vcrX> zS(2m+FudJ2tKVK!z+p2zMipBS>z)#MAY{2|$H4>;do_x-{V7jdwH{Nz{VTPNPvJ_O zs(;j?Xe<>aF&9-$e5D6NesZ*&(73yUSyHkh@}buHkR9P0&3$23xUVNlD?hut>zp6m zi_&twH1f`RXe@M+^-!1v+7@;N?kQo=D{|xmYzBTL3XDt7f!{Efb%cekOZ=5|pK~ft zKMRz7B~(P)ePFLph<$QEE$jxDAQjP&4$`_|0i>pof1W{W&;;MHBm)7-1x%8gP2$xw|7L*w|&#l!S z!@1uZeLc2(JIfmHWxU!e>HIZOsyHlw*GU_8XL!jycMv7MgimQ3+WkJf_b%9bx?*=S zaY*fG<3^>K*#`_$OEDH@iDB~`uMV~%pl8My=$(R2L3{v^z)y9gXq4dt>Lk~<*lmL3 z%DYY9?Xq3GKT)X8+{mAApSv)c2+$)QCENI9IRBV-D3Fxa1_%2Jk4v#8v>sz>nZs{j zish%6OvfGK;NJYTM6*(e=9ZnJTs!~-{`evy_^-tmikeQYxL_baGYnUQ&D2-glkZOr zP&24GL@#vd-k9@z%sQBx`szE6nSM2j6w|wPKOcnRH+GUYz=J){@6{J^2Cq>=JP}I9 zIKfL2eKkFu#QW@WR%}*~9`~z)*R4kSJ}s?8;w8J*EJx*wml25PKng91rB4Pw`MRAG z#|$$Ds-PdiAZ7tj z5^Eyr{=|F?3*@K<{>-{3*c_fk5s;DGc2U%;eJ*kpyFqr@km+iB=d--X%$^8!o6yPE zRhNSy@tJ&ET@eo8aDY0#pOvu7QZsoFcCZxL4w*2kS&8VnpxV1$3|5~IubrMemo?)y z#DC=0_-2dnDG3qotj!8xb7V{e>hWpz&T&JL;cO0Az7Ef`goF`)qWfy4YY{jGn1s{tHbei@1PQJe`^a+;z-e=f@8zkvP`mj@7y>NcY8JV z#R8<4+))MyDBg^5jPWOZBLnyV%Y}C!z@>qIEtd~z{H zV7>elx_5YoYsXP1$;sD~oa9z4v@y+uGa2b*yfIck(c9^cTg%0ay7okzGVp_h1TRlH zMar<%o;6tFS5tb9r^~M!>~4fg)AuK3O)denrv+xW9sNTF_8#i(z}M^e=4ja#Cj0ta$}ZJC zQcC>qWkPksb38(l+JF00f5=JcTk5RKt?_NYrFQIBs7<^<+ybg6O`rE7<)$7 z`|Id-!^cs5_wB7(7}k-iTc|iQaic^4W9<7Hwh}2;gN){UFeK*%>6Mahb~n>5|KnnZ zdGpeBI^P;k@V^=OoLF4t!Yn~Jg>5#j62%Mad)6h@)bQ|{I`&lSU{ZK*s)LJ^4^O@c z*`1+}{t44Jb<M?Rm2V{gE6Vr4Q5%e~nm z>#38Ov2tB|YWSeJY4C(kU))v{8XQE#old((jXMk6&#vZH3WT73#gTGZ`b-YZc^{hG@x@-r3{y$k285qfCh+0 z4LfPtvanz}Z#u$?Cqfcyly$IU$BuY;zi)Pp7n9gGb%MhUE~{nD6+O_Pe=Swf?gldb_|>-|ll7lvXu?0Yw-U0imFVtkJ_@ zf#&vqcR9zAkskagQlH;J>%r$erU6bXR?>vKJo%i<*fMEcXy;7nXy%Y;A!L=A5i^_TVJoXzI)H!^q z)e$ck7p-mKr8S(-vj9@IiyEAZA>R`pNLyCaAHe{}wBXag&NLaSAK~~C?0>Zy^l{1t z;eX!!dkXv?dG6p^$crUtV*JYcrc%F1rHxLv&tuyV(a;oXaeC?=U%@2wg{3XN=zcUM zOs+?p566!!_{7a>k-~-WeGDX!BOLmFK-`2>9*wRyy!~|}|Bq$}C)HJBt!oug z9ygQj-q9dfTkZGReZFZ$mX#Ez%WqZdD1Hc(tB~nW-C&qPwH4*|fF$u4_fVxd(Xw75 zvFNk4{eTdB%>W~!OzdOVY~ugyVCn!}8_eMj26IA&3+gcp?W|EK2(LqS5fhHCKl7kn zR-i4fNemzD-^s$g){wIB^i1^EetublkN53Y+jL8_oX?tP>tlKfL?n)j3g`uZr?!4- zec(HTc@ORTc(MKcNYFB=gm>(6Xc@G&h}%p;W`ADdv}*HF<4lj%<`rp)1;-zI=; z++Bq$!fQjnrJBk!|4s`a<*RfjfS)Hhr1F;4_b7|~t)5bbP&M2*3hyWDm><-g+1!j@ zOKd-OIZO_Gct5^F>P_(*dZ|Sq$SGssl%@@VF{<5|uvS?==1-AaMfEoTRtQ}%p15!> zVF^iJ!KjNsXj0R&2YLOOgF87Hq~4CODRDgok8~m4>a~72)Vg8Z=)kSi<0NUmi>tK+ zYa`V)diHzc!BT?Da~;#f9m(-^^BgvtsV}X&mVTEF1=ADM6xU5JP3=heu*m>YgJtDh zly^9VZ`33ISNAy>jiO|jU+XX`rv7%E0c#l!F{l|nZqtaTSk0UB&W=cPS9heUTJ~po zF)t>5-%d z04(=#z&FH~2nq+VSTe?*#@f!5GN)~|xEk89sy>g+dea;&chVeA`*)(5iBg8oqB>C$ zCEeVK#`$sy47Vs6iL~cltwwBf&bBXi+Q${P>M5X#{Oh>xi9Fuu@%WX`Z4a3B0xKt?;Go~Pepc$XbLSSNy2Jn`G71h zYdz@kMay_d^G+EIt|erlZzTGSC`Z(7-q3Fk%n_sJLaAx?`-8^Osl)ja>m*C!?$2+B zWKgW*c}-!&#Zn|UPjZo35UM|+f^9@% zEs_ccA1JjRI~Ht7MS6;)Ro^o0vgaAyd^I!Hhp_*&QLG^|v+|@vdS9&EW}H+!1Zd3nCE@wd@x>7 z;Tkvlmd8#DQ6q1+Ue+l~=ODrQ_*Pe-9lGLo(a^e!enzXsAdq|wb}{%zzmio##hMV!NZxC8+7iy0iB)Kf4=x?6_rY$9h{d(0$Lh2;e3n zrfaVvUk?_jB;Lp}NM=My+ckgiT*0-}ao7q$(ZM};Rq6X`BAYvZr$rh`>s~TjO)4!3 zVyK+v^K39U+!WlU}^6&?9VcJt6$NNN;hI|6Q!2k+?r=EF~8TZ!0gBieh-2HuD?RT zPf&2*V;uWIIl2fXv8?T>C*uCOOf)bitF4bq^KlJ6dm(Gw_~`s&&`=iVh@9;2qYAC# zi?j8^VD|(?Llx!!`2V}#zeRhdbjmbtTIgkCJW^Ot{jKe-!SC8l#tZe$&f+U@0Udam zE4(&czf6x!2$Xz|kl@8P=<5q@eE#zPpt`@E|1}+U-4-AhnpWx7#hvVFX`+c>2#hnM z7Ir)t1%pmblK0z4z0Cf^%Cw2^J#1qO9Mhi=(Eg~}Ym3@PGKT^7PyZpS=;BTEvGbn& zogmm9*EjCy!asB2l)58(yKW>kui7uAT9HzoL=`7-P=d*AGeN~h%txwHv^ZS?zowpa zO?sX#K3*6c&#$R@^L4I*UdB$-_~pnz{F~(n3-?y@8p4Rnodiwoqey!$mtS$Qi58kc z9#(H|*OD>REf&@%<3H;=U%EO-FO_JWd-kq|K7B)%tC8Wda~fL>_~wC3W?gk#iy}Ff_~<5;_ue(h`_1J|nf_se z{=|vh$kZVLSA!muAz2eToOz|E=&VVQcV2Z{+WS}U$OLV@Tkrbi6K(5tJMg8HIY4t$ z8@2j`5--M13`#Dl&e$g4$%wXOAIKki7vg*sEpgp#wHja3>gZetNmWYq+-jhMOY7b$ z4hn##^dXrvU3|xa)huIRXCmLm+Wfdi$Zq$4b`z2!;qJytA@R8K9LqxO_lne|!#MFn zgqs6c^u-bW@{gJl!`oXszJ;68l4ykHlna$+xr{6$f71R0g^-niB0~Y;p_%R9Zm7-$ zOr>V%g<<|9(uwZD@cwOh%gpfiorXx(DL}%kxFbAcOR&x^6%x{(cE+|O*urc85 zY+3CuyB4AKA4R)vsF_f)BIGRhJRAXc$On&T$E5I{oshUcEJ2#8jqh%?z7`5$n7+K$ zKep{ciy3hLUup+xWs(3ZX`l5hxO#k(#jks`5L4yWMB2YCooijBujaYBf6RP{ro%}A zFq7~xdh9ahe&Hy|Yprbv;1A^+?`r-rTX0#|p&UX_GHJ#`$SBtPE$?T|SL!l5H(5Rf z_`ZR$@{fr0B;Bp$H!+er-+;syq1@$ik`^gLp~DF%3B%+={ki|X4@ilS2{U(~D#xEs zDa~~=x8xm2F%e$d7SZvbGheKHzCrag##gsA+qZUnsl=#HLIg=V#Ax&!*i{|A{BY@k z&@`(sI=V~~BO-b=RJ7cT%LAA3Cb#W!$dO5XhU}_V`uw4i_~8P5L%P65h;9c%dUc@n zs6@7Tg=6nr(?J%wZeVT$INxGo8;pXyL;XU2bxxj#xRPdrP>nYH*#<0?2+(9A)J%k3 z!dM+9;=>7UCM$24I2sa59#1T4R3oYcAb7omMW(O5^g|gCU6un}MGEJgb#5Lrs1J?! zi^|#^wnUX#w);QLA}YnH*I&)c1PLP+bLS_5Hu_~=^abC6O}iWQ@!P5(jUx@)qxwQ6 zR8zxU6v$NFE~P7*WZ*->PlefShXk2Th&~ctmcqrf7YviSTFIP4SO5Szt(u8kqXeJ?xWYEY74MZOvQ1GyC24x4-w@ngFiWuicJw<4TICpRH(Dm%EP8=|DbiCs;*m zz3}9WnAGwd<L$OqqoTT)N2|Dt9qn&o{>+= z2P`bk6LJ7Nv67_>k+(5&tL7rHfdOEPqDBxuxH*^Xuhbmc)lJKA%gfT`$MkVT$Dp6J z<%(W>(xKX)+|$ucGisj-dOX)3#%$2271qTyVtPo;IbPJJH^u(4J)StQ~?OmR1R`y8jgpEd3ZaoP{p1RgFgQ{|L2JsZSYt`QK0KNo-+%S!OeF zxFAF=r|mxbBHaH3{w|-ipl~DHSjg#J(|h-oA}G}aI3gi?54%!EzJIX^zV9McQTA<; zL^<#7JeY2EH}rD%%y4sgWg$z^pJ%~BR}G&|Jq~@X|GzhGC3Pp`!u)2W?=h0vU7-aB zfQa*e>872jy<@8O*|sz?PG6?KaEzC6h+&l5s?Jvihs63SEx(55DXe9<<5n{~;l9G$ zA}A0t+GbdM_gUbJIe(vz5_n-Xz7gUW+MRKD)0N;O|NW9+LhzolICq!e=f#XpD1plVVL4qo@_fyg4PbA)<5M)I`q&!+7s{KPFL6R&Ee!D3}Sd~edoHN@>>ArpqK zhqr&ACY=r)R8sPR`%b{Fq>S_R_TJz;DHvpE>De^oH*lclh{a)@!g2plLvG&PIHSzu zae!Nch;bxub1G_^e|V`F9B>@Zs~Oy>dL8J#W^46IA%wZa_G7%o>tLyunZ6Z75t%{O zhz44V#$^$lF5aZ)gPXSzjrWjcz_3W?M$)h@LgX%3LWwe)O!pDlUl~`HXHSp!ZAQZv zyw*EJmWqk#re&>4u_Bvgpm2qxgZMG&LtS-W4s9qp8(R`i2eJ~#rX1g{SYR8!zCXSr zH^`dTCrzz1_NhEghCBJ?*s@-xv=s}mJMjVX$TTTpdO0fbJZh#y;ER&e6@rvKKr#ikwX9>#y2~cOrr8lCb+-pkxfW zmi4{@OH)Mypm25mr(2=%B51PP7v41JOP1n+R6UM9bBC*xXo+#9JD*ARm1addz2rawfn}B7oq0(!k^5`YX4Iwt zZYez+3tdP!mmnV?)k;vu9FZMBm6MDsnB{uqH<^RSQevCX6YD)mH}ALZfP;Vu7tNxd6kjudsYDyZQX=$4{7 zOomIvPVkMAEYXG23BV~|X`uWFg_!hVe4cy#TA>^<9R~pU@J3x2<1FqMzPg>=di=8F zk8b)tQ;`HU>^w0+hyfry;J%u%HI+|qbgHxb*X2^$crZ6^l4Rr^>mE5dT8a^|F)x5{+WzXBp@IA^8~9Ub1;$;#wNWy% zK0LyLZD`MutZE;x2C?uEstl9sKPWPjLZlbZ1|_rbO*t@9sGeUrZ|PWRw0R?6dB&^| zSC5*B`LPg|*yRa4sEwOM5C1@LgU?73JGW9)LGE84mVd?lA}MAfb_yhv?zFtb{zLY} zS>7x&Xp=%Ttn1-=`fJtVl8Zhomk%pMR!I?8K>%JamBy}5==iMS5{(vAR?RWD5jGOW%Wp!9=pq--qX^eJ>0{p3zj?}jneAZ@SzXh z;!WMJ4>4wQM>(sM$9r~gO9S)XuJ7kX+;+WXG&9Ww!vMejjp~O9gafXRhe_IQL85uL zx$-xcu%ifW2}yix*yYLWtDqz?v$Fxn!QznLt$EgveN(Aj-R`F!q;P>h>9}Q0H5kVl zbTM=m&4%Mcjl`#zJ4=lzI4g_;K4p^?%JQN=ME}DJTcZj*+X)=FnI%jo+(J$Z?uYE~m`Mlg_B` ze^%6G5s(P%0s&440N&(d0GJmW*B|A2pN!0y{M?3rdd)T7TnZbkynYbxH4b2i;Ga#g z{Ct|sSq#zklQ%Tt_=-Psvnq2@+A!orS}TGhPDnhV4s+^>Uk$#_OKge|oz4X8m{PDx z17+f;&*bPGh5Yf?jz)rL^r2l8Z2? z0%J}?Uin{1ntD05#nB^XQ^Vnsefd<(y4^HoU)f5VSRjV=AW1){*$mn`uKQA#6kG(l z{PMcY%O;dq;R)x|3v{j9?5S$5#{g{(lDYW*tkg!5^iDfP5y;5<){tX#`5RhhkEXx0 zTO_wdUT*b8cqF)fGjAbySf*0zwKDBsK=y;yQYM^UBeX7X_J)-*Piqy@7vh|zj2+w` zrF_{>jIV=6CgFSf1C=Y498C;dOV-?VGSb&$Ubc_6H6LUi-f2n}tXF!vJ;;}DskByP zGoB)HSdcgRwPYkMlTj3lta2M4UIdZURa)(_NFGwN=K&RGNS;O0<#%h_H^Xn!B$GvWp-9%9IYY|_h0udiy?LTBK$rUUF3uI7tSftuz z_tv%FATc2QFrrz;^JvhRkZ$~|&&KKcg+Av8Aa*)SmD29m6jli8gk<{;@^}$BITbu8 z*;8SJcmUmUJli1@O<6bbqQkLgk~xHJ9v&|DY?^ohggC06aR}dd!c{i%xK~6mOC!s& zSk2g}*9WusbmWeJobh5jRhibX*07bY=cv_n!<_V$bFoWrq4ObgzF1?07UW<<$bu)w z811l6o5SwqplVVWkfd+|HW`a1F(OFV^@4R<1I+mIuNl#m`?@yO&ML&5T)h%#{>KmT2F69U z5IwFI(}co7G`<^2(n+vn=)@$vH7K3ES`(@Gysq5OHcZBlurtrvahkI}thK>ud<8Y) zTz$8-W4JySd1&BEI;NHf?W!IB$yv{`Dblwsmgu_(ciP>R^$p$yT|b|iTbWuT`ppjM zZkIwDhca#K#$q|docB!8LdFf^Y_S{^u*6ZP)6T6RIC`IwiU^nT=r?3mlU!(aV$PEz7 zfrFX~cTMfh`Eg>?vFgdUb6ZN4nq7!0kFR>uD}^QWq|X{tYE(-c@o6M-;Z$TDYBE?1 zUoeH;eCPgc&jMk2t$&JHlnuSF_Nu8ehzXYZuRCDnEaCZAQh_C-NiSd$V~>Z~`@Aff z*)9>2!O8jqU+1%3hti7Q$2GcPd}(R@MG4)j&u?mE7M`_LmP;3nK1rRB#=4}|q+%HR zGdsin*HN)$w1fmC;iwAiCa}`28W;Y^+7jt9budUrl(885c9FSZ+2?7- zARK=-C=QlT2Ij*-5Fo}7X{ka2_}nFFmF_P-yWwZgNjHA{GkZARar$D4Fv|T68c!$k z#w*<{wFSqTSZ@6Ddvn$9G$};fPvlONx9+Q5<_hbtvsKH8I>57X#l#I!JtEd*QK2~~Um6mW9O7e?LOYGRUQvEOD-aDwtuHFB|E+{A{AWft<0i`NZ1ZfFPLX*%Q zrAPvy2c+BRJtXu_0tpb15_%Dl-ULE#(tGa$KIit`?>^_*d!PBuIrBR+C;ucfOu{|) zz1DSIYpw6~`TE#f)U|-ej3h1XV|lt^>skLYEg!h_Qkz4hAfd^Y2? z9#sMMq2XLq@CuqAdz)!4l47f6g->9y-~vcGPp0*{o}Fk8j4XtH(%&v3RR$Ed`mvn1 zZ2xsGCL8G6^JGiw)tiOhr*Ep8rKU)>TV%M4g)epd3tLGyZos39#6$;CweCPG%O6a7 zjJ^9kjJIH1=+n;9g*|4)7!(*RRxm%50)(cM$l;sOV|2YBs_N;z?~+akM%2Se zh1V%8IyIsip@Bx!+SXmC5d&Ee@_ul))aawK%K3mgwkC0L@a@lx1Ig(xirD?eiCa>8xz!A!m`S|eHSiYd%kBInGPz|WiWND<6u!@3v2RpoTK_VzS z&}D#>K=<}Ya5fvU*7T`~QT1raehoqR@wh(R;1uEzrj|eI!c92FNKb$*3G*N%$mFUI z&R14IPC09JMM7}z{l5ku1;tejSuGg_@*uHhNV9#ARlrHkak}VR9u(u{`_$!CDqAIO zcBNHLh%$y}C@xm1Qr9cT`de@hwPh+d-234)I{a2>m3V0Q$=hY^%>|iIBZ<1j` zCW*3Q9+H-;XIaXlmrI3GF0{DHpXMFH2KQ38oL4C05CNd*u!pdkX_yV+u#;*7#`p#GkW5*o#( zU!TA17?_h_5Io&7QU)jZxNYCKM}03D@tq5UMSm3MVRB2vcBW~3*p^tDd)0$ETd;pA zNsmpI3lsD;s2skWEU($%mR+*M&-Y5;7KW3+P=GbfX00zqdsV%1 zI!#i49>-}@Roxfz=KZlLIFYDd!6ihhSW^y(;m z3OjC>NXJ(%);;W28?Ou{>6Z#tPSIZ#c8I5jmn5yeU(1Bp>|>LsE+UClh^Z$ zQD^kkX1$fJca}8A(K`W<=?c+~fEt?qL_^CqGnEdHYMih-%;)JsCS+7C0me-*gMkvu zOSFFeJHq_$zs<_bO_->SyI77q6*sE#k}J|C^zfrw^r%-{a-OyFYN@@xfwV$x;^;I{ z<^vq`bq6_Z7J`DUKNm?S_#2EDdhE|lSMq)h*g@+nTvaY~!563v0;X>1ym&bhh&~W* zH7ph7A7})+wC=`oafx_eH`^jgDV^vVDqNE+>VE#J^7?7UWWU7IDg<-QtG-41D3`)9 zRG^*y=-rdsHZ43@bda_znq$D)tUn&a`w7ATn2$!!kHzoMAvmxF-LJkH$Lh5Vc0YGZ z%5TmZr>Bb5O%l% z3!;f6m7Plp=TgUul0-zQ)87=uYDSRCbybzq z$Xk8Rs4~Ib7h{-_h@Ih`!A652dH@FTqEcwpj=v8L=~lJMt+S<|FKVHkR+9K=Xm$Z3 zUn^f^zOqHT@<5$^s@635YysR|mWb z+m%%nKfm_9e|2kh4x&&cRkTCWeO-SLZ+R=`bJRD3)?d%T2u52*^>Hst&^;iJ+fb(~ zC(60LmFlWkP>=gngRJA!h?HH4q2=KQ{EoOmI+sl;)*nz@;*o#Y5LkYG`l_OVK}^UB zk!$+w#m02yLZ@o&K|n6Qht``<^lfFUWnNvY_Ndo3;N2AW8|%b*+L$EB3_coej1422 z?s)q*T_~O;S$uN-+V{*+K_`J62gO@pR&5~8Ou)_+rE(S&laASTYAAx)CFDq%G-19qT$rlu{_we1Z9bqnf-cI zm4#mlG>rF)E~dzhZOiz(7+pI@oSyu}=G}PXl+iV6{6PAIF5u_&nzBJx`|>hlZ9L-r z##IKv5Ki#KlS28RglMp&ETJReEqChADoJ(u!jC$BD2|rG>AQjLLx@4WGC`Rl46lk{ z#=i7?|9WT{z6VgK-OWjdizR!O0;T}pfzuN!D;rhSi;XRkwaN|D^8TmxmODPWc;Opc zDi!zVh7a@@V9#Ds13SZ7vbhQ>1YO*Kl^i?1Klh$;d9rl-wN{Jx!M>qbBQ|AD)qPCV z&3vzn&n+u%3F2a2{I+};hrC)y3?u%mB*D!Z8{szCrr~dXiCBcq{rdW98b98$QZ9w!_eT!Lg>C6o#!YXoKQ2k^*96pCik(UBs#Y%1$y#@F zd*%%MJNHGEmKCIX_vCa$v(vn4oGxAF96n>QxZ$g@sbKuuNQaKM2D&ZJ=J2|+Y_(+z z_jECZeww>6AAhoYR^cV{^w7<#*17fdU(e_V-RUr#hY_XS^2u~x{1hi6$TWFKgW}d7 z-F!xn^e6K^hxTXn^_~asZH(M+1&Bi$Jb;#oiN?0=t%C_t{Rlw1g@ z=ALoDhP#gOuE#fN83^vL#St`~)*HLwT9ZHVpk=mnD5dpdjdA=Se7gYW@$sdmn$n(x zy(_n#%~IcQ4s$heITs8Q%Xc?eMX36qtm1v|TU8iC26`cPu}qm(nA`yF@Q-#BKgZT5 zT2+z}P`Bi!S4@~N>(KS42=%}&#>Fl)7E8uu$+`&-AY^F;deTsm+~Fr;v_aEQ`+(i7 z0zVo%i=_9ze+#C+-xB`4JnkQraWf=hN;NiD%L(=)-D;WX8jBV4wJ+^-8H0b-N8Svt z0lYwVT1mVXw9!HLfjQ0opL6<(`8zHJ8)*XpM83pzbqmC@c7>>#;zM8(K%0Mv5U1|m*Y{T= z%ADu4zItf#8y;4;9T=0~mjlq9fj!1Lp9|4mx%2$}tNJ7tQ%3GIjjE8Q*Y9H7E$cTD zhJ}*ISwDKflxo^V1Gr*=qAz7UI9m2&P0I`l`#6BwIU4v$J;qSGMmVIU$)-W&1D#3K z(?(xGhXL3mGod-+RbLQ9 zGq_a<@<%XtA}+7_)&DC61;hDI!m*y?)``NP|NWQE&%^w-_0I$0CCk~sUH zj#8CcE(lC!cN<<`=4#3ILzX&*i3;wQi}XtRl+oR=73Z?{z%?CDjRP$$<};E&ITReRkCim`zYoZT=_SJIt3AMNd5io6TL4f_ne;)`jDl*){T^FcckOEqO{!PetJCf zi_UiXF>xT8%#eY*{8?-O5pzfRBj>V&TM{)lOiYaH_Bim1um7)KEVQ^(aWFwv7+a`0 z%W}$6tcsxDIK@)eI+JUSv)4tbdb7!VdDe=;nlU2E72%bu?#j8Wo;}wqK>{`)k#P5lzvI<-Y%COkXb^>U2PGztB7%)(-_MW~=-UX@k zt8?D$edRyc&3FWr-XX9o-u2E{-TFkTvltQTU$`i18f@_WtVO-nz%Avnz6|4hQr(cV zF6C6AY_?MsUv0J;4c$$mwAnMXZ6{(}EYTPKH^t-Da?YWgU&6dXFVho6fayrasznA= zW4Y@SedPq^tHkEtd%UFY`)ApxJdnFOpf#u^6<0K~2&F2{t>iN{m?f<6tA7TqO5ZJ& zztj4q->+C`a4zku%mb?BqoHIDAACOhr=;PdRzo>^+iD{(D@dB9l-Pi~Bh~#Sr$947Nc@`ZhY%F%upBuENJv7vI zx)k%xl#V^6{04bn6PHptvyV$fyhA~tf^M7{(#sNJzQU=O^IZTTreVZNQ2r>1?*@zO zCZ_lI7k)F*u`LqUJGheT%ljQWNsdcV5hOyY@-R9tB;&y;u&RD@M1_|p=m{FthR)*R z)ZM*v`LgS9a|FyHSwqf)0x9)Y_C&Lcs z$qan&!r&c6#>OG}^83Z{9HDNZ1=b2Wu~gt>y*6j+c;hEv7(gl90c>&`c#Qpa`3%#A zQ;=uZ6waT^58V_$iZy=G@HYkS?QhJ$6$a{%*~#!n-SMSJ9ox{um&7*Qgv7x!z!xy{ z@~&VWn%p1V2^Wz?gRDz=(LMx9i6j(!J}-Vsd39i+G_v76KuyPAi{92f<2hm9WPG;M z{lMU`|Y)yN#*ab`lXA+jqh5O*z$%ED}L<9SBWnODRr)BT_H2QU}4@K^SOR} z=ZGrzc-D!qf6OUqO*ZoyZS`@T4rTrQ?tI2qhM}J=Dx;2j~-E3C`{x%S6$@A|mB9(w*CxAO&zzdjYG7K*QP5+^5`L&pY`#aXm7> zFQxy^V7F`1-R7~Y*nkxOj_3kR^RAUGwn&LgL224#UbH33n++<6G$-Ewn=B zwAqC{{$^Y(eYL74kHPD%Pb1HF^Lp+S_Jj@$hs*3~km0RGv7bj;En zpNy2{mDLq-9V1zG3XX<=hMA$p=`9CGz=u#aC^&v5xA$)fM1h}Zs7RExk2 zkg6vcDYc$AR(7IgjLwJNC?3@eakdEISONP~LG{=UaM#sX3BIa+nLHM^Jp8G&MvEeB zO_?jd$|Pbx{;s#(!UQr+Ck3lXz6u&P1=bFLmZ;Bmah}YBlqWwRF8C0k(%_fUWiSkuX*8($aiyT&s_Knzx%Pu9ogw*1AdZkEI1HmT^hIl$qy9-v`ML&pAK;-r(&n0U9 zt^PFB{PFy0FfhXZXBGZ?&AyJ$E&|jV*^%Phb>$td{70;$fSPiGva-s?<3Hc|zkU6W zqVmJGzSwSsS{of*M?~3Qf=T)_+`m9-CRu=4*r5Eaqo`Gifsudz(#;DIzi;j?_Fj&A znT%N*a62k6fm5Ro6tzvF9rEVjH>&lA=1Sk*u@ zXwQej@iKRt;We%?`bV4pbbwQ}pMZj(GChRa$h$Jxpkq9C8-82(Q~N|k}*DSvD2 z+o$RJ3h}oOW!$zQ^J|t*#u-I2kpT$u!w#eYT|W5~g5k;goRhp@H6S8uMx7J~6ccT~ zAaF4*G0vwm4esCar>LdOPNVjoG5D_7l&oJ(DNr@pKhZxjS`_Au>m-VxfaMGTPO(u{ zX?-B3B)7f_Wi?u%3iMC*i=Ij(}SUs;7r;y)IB z%>-=Sapk=K_o(>)?Cs{{aX<2L8o_I%W$Z21TBeMJf#G3@E6JfTW?rniEEEF~LQP0G zYaz{=>ll}9~OFUKOA%ww+l`^y`@BoV>s z^0L3edn#tt5Zb)YA~Zle^C*-C3}##ox9g^Qpbc)r>k2a-*oFhf<#~1Ee^XR5(+~cx zlk|V~4HS7m&n0UZ`Kxv!!qN2T&E2uL`T|QYQ)v;zevlB+UJZ7`Ej-0FfEk?)?bc6= z$T`x6wqGgF@dt1#OEiXU4L-AnjH7uQ*$2dD-%hM-rGnlx5*9RQAFmB28^%NUCXoEe zVwPX#h?i|+1gP7=Ubk&9L=uiv)j|C_X?l>I1F+X670swMq=@b{A_$rrJ|ibflv9Z^ z1VhsL@xB+X7>uvg;Z5az-76pnv*Ut78q{pN>kA{ZARz!dpeIS*ZF8l;MCT>@hw7!p zti3Z%7MHbKpz^D>oxhTr=)I|=6dmF<%a`NruVok*Iz>9BoEG0#GPRC<$`8ILr7N+n<2;XY~*P1SR-3u%O}u ztA`pK`%0?e?Ja5?n6pQj{Hkc^XFOCZHs5I^Ig0D%v>D$io<#Iu@!2!+|Kgx3@+`0}MwoJ69mg!w8JB39qq|gJ3W3DMbkrna z0AFy}EydXx@{}KOvskZj&!yQ~3KMJJ-!qcYjJ8+%T;(+Cr|~)Nm)C@IlZatG&I!$z zhUO0ava#NqAS`pnk~7i~PcM(tOdwag9V2tcy~H;FN);Yz2pGnKcxdBY#JCu#XH&-V zEk@N`61~zJ$INS8c(T1s$`1bmx0cUWcmA*lpvP}XK29JCJ9l-3*h0cGzvmC?5@>kn z8yyfAswW4WzLpClBi>43$u^P<&q%Ds20?5$oUJ}5mvBD{Ot25{B(yT1A5=lmC@wMT zK*S*0v3927kfxhSwHm3BwE5j|(q@K02poIriqqq&J*}h0@vG?pUd#{_;8{$|_heFn*GTvS-ITsk7s;#L%gCHCWE;GMFMTB@ib}QJtA;`+vbCzv1-l!Sh1g7VIwkM zfXRL{pm7ps#55Smi2a+;nL$Rf%CQat9#EE$(BsGI2W zw$Zt;FV2GYeqT-aP{qr(s#eAohSP=()1b#mbyrIxH$a0qNr=aZukG9S4Go>Az+>Xe z#*Bbf9dFjsd!nW#LH0bbgq}rk?q-v-?&b}@jEON7ZDE6+%B_qOa4;I^n5MARwg-4x8l&H#I4lS{t?G z>~kgwIhr=9$C%)yX{(6?k%H)IH|^4&ld*yt*I|JuV4EcSkkLWsqg?uIvU#@En>B~d z{L21?e*W7tU{fS>vR=faKr&36Q(Q*?(D~K)C|}wN9;`Kg+dlSg>~9LG#c*-=2E}UO zQwGgYVH?5Qxv#6?V8HJ(CWb#m?OWvnUt|1L`OEv4G zw{7jo)}A+OxX?s?y+Bp<)s+=4`H4gqE7QcQ{7crRnR-bF;Ov;S-nQt~RNo``mVkpW zAehdUborO{sOL6f)k03xDJ)xut_1f+E8e8lHD8j&%PL(ipf`e(%f3w~vAw+HMGtrP z)wC=z9@qXfp%TA-7Q(>WI_;S9axcA@Nj(QadCr3B(Im~bZXsn$@W6Np%0|_!tt+75 z9k{%H*yj4sd2e2X60Cl;)tv>XB#YOr4UPN##orRs@1*t7iuWEU)^r z#Kbv-Jt9V%TuuC4H3yK;B;mHo`plR+Q+} zmBLY`MEJ~g@DLXJt@R9E9n(|ZV)-5FQSRbvm};XzVlV2-`0C#d*HRHneJxsvb;_j{ zjt7HOVLA{%#E)a@i(ie327q;A!`s=@JE zQT|V0&fQofVM{-@!l+B-*@3C1f(Om8V3z07V(^;$r^TJV6?3&8)tStYd4&&vx(tCe+z#lomQxm6+UF z79U!6cdeAN_8NIX_;9DNoN^8S`m;R&Z#`yEdv0acdI0uRwmtuoo{e?ywMOje`{t1w zR6|CAEbf!M25>z@EyZsG(yXw2n6srCRmT2jf+|SVHTYa2PFdNGyQGXSt%pA>hV8$k z=!_>6G=Ee4$7A$*|F3&_EA?5mI{WT$lmT3OWK>s2W1Br zj#&!*;FsQ0Yyl8^54UMzF8gAsgu%cu+>Zd z8YTaK|9{^uWXa}+r|Nc)<+zEnjrzdbhy9&L^dF?6sQFqIl;h>fEz2yd9k}1CD(3oM ztDo{HOq7UlU!4f+0@2XHKI0hN=>;cS2QZd7`C@Ay z#0xjL4T$F~BcaZm92LA*g)($B)6BTQA(E6@yJNwXVp0i^e&48Wu?5bvWi(i&LM_bE zWPgaG@e|w47a>d~FYL=ca^omL?f~$b^8q`svVMD~C6N$*1R7!x8&F{pH267ETwLIt zDm70j|ANLK4X>Y7Xc9P0g|zzaKCWgA5F(%c07;ul_P4Ia#3=lGnltvz9*y&vGDnj) zpANbwmz_AzYrtx#leM#HTCnPQ`YU^h$?~tC>_6Ec^heIQywh0W;ShqUJB>@0E)Hz{ z$0Gzb<^J`I8LjbkKwCiO%nuX}Hpp2R3MaWm!Pm@=8{PK6eIl zz1WQ=f6mUNk`VqbAWZxwA>hXKUk2+17jr6MwYNeCOhp%`Mx_XbWyH#|B;wU5UH=|s zN=Pa?7H98WvX(~_tlCjgF9wv)iOMac4Rc2QWjHqQ|XPW310C+_|>Dv-7{KEyme zb^pMiTYR6Ze?$YtHdCKg!H}gRWC5uJLsUT^TKE&2wPrn%Uwz5Y>_Q&vogASCPN)&G z_#&&(&Uu|kG3U)-dt9zbT$fE;`O|rHK%(2j@HPxkq;hDXWn1HglgQ6dlO57GnFHkY zT>k0~G4`#ntQ`wccc$WT0RYXaKyqvgM`YWt7x^zCYPS7sJBF!Nk0b{}@dnUy-4+uB zWKrQtJeynp802$OT(&%&ve_x^O}`)RpC@hR3zaGer}j&R^Ez=8?yTvdd3~?ILs?+0 z*6GN=L`9}}y+{FmkPFbF!W0pL*!$#YCT44NYh|4KDW%bss0ch+W{gmjT->Ao3L&-? zR|?irUsc?;QEB&i{)}SDtFC^tzyv#y8EX&C%^2F~@-WHM%}KuQ(YK)XevkO{$E<$9 zK9N!$LyJBB2{bG$fBLo>#%#2wnz$>%M&eenA-KOHc}Yr+_6XeMRK{z68B2jca6FfB zF^MS6wyK{|BbTU+a=f~$n78vRj)qJsS4nx3iYtTgl(EiEFa3B!JLf_C;3Q!5n|Q9r zz%&CPZ)ON<%{l+RpKYDjkMIe&NTS-wY>RVLBSrO3yL5;$+mP6d{)c^yRk61HNoP=Z}DIQc^GcAo%NP@~@JBeYs0 zvmDSDf*?s6A9A1nbAfxi=(}QC$?qg7*RE0T%NDVSS0q(+hmbU-_ue+Mod;6N>9z?>{%{7BE>YCetE zTqgVgG|UoSCQXKcDzS9v&x!a?%>PWt=8PYUAjV^}y;X0uJs4i*>p#pX_-^`m`Ods& z_hUVQn7Nd|ZXW_k0Oy8BSz-1w$f~xA7~TJpmKh~yE{&T&t1Y{pzR;6FfAye|%QO&n zGto~%Zh&Gm3Eh~LhaeMt2uE3^ZvSUGpFuhZt_a8GE8=!V&@RXzDYCb^qRse)hz=5x%UKkWN!8ae0|P_#dEO!D5_?K5?SL2| zqHJe~bG%8uELaIxl+vPVn3!n8+IAc_0t&{1l_9p{eEwPb{v`4eXtj1(!+CIZzQl}?0VO+ z2(Iuk56=PMqX{5=FRps<4%-kH-swHQ>SFFvz`5P71E~J$+8WCzI*4*)eac0{Svu8p z$;U|=VQSzB9jHF;P9$CF=&$T~h0R0K8UN8nkv;kG_jdl!)A9(a=zxjVP;9n*J~41*6^1G_s#d$Gzldf31h0 zR+0J8X1qzMFtZtG_8q~2>^t=I^FSz zlmGjlFJ{Q9%RfMG#UFE@w$LHS;R_b}kCf0_hvdNR5v;%zt(C#?Hf?MU<*+vdK%u<_hmbvUFF3 z4xx%u*+7QGNQM0Tl&u-%EdY{;*_mijZ{Av+ny56{e)AgN^D>90?dbATopO9WkJPt( zdLEuZMz&}e^d{OjJT7*riXyW9%*3PUu#o?>!+Wn7F~ve$-!t5BH+)fsWU}m=F&o)A z7Q0V0=}Nx6^gW4ixnDLw0FG$s5o_zkw<}}QE$0Jut{fe+!Uiv!F-PVXUTUUoV`viY zkc#Tm3o!$f$3i1;PN-(*m~z%H>?OsFKhNVqMer@8$tjF@PqFhjt}WnIjYv&ujo-9a zen*zxl-@0M{h(wn9vS7pyUHKM7QiB!VKM6R|GdgKev)HIC7Y=yJyzu&Y|Ma8S|0AVJBf@f;v$!flOJuR`q~va$QI|NDp}qmnkJ}TKJnl%37T&u zmVIY@%h!gmDdMmhw;4sDfl9sCl|xV3F+2(xnmVY5A7iK&!S!3|-*<`EjInFrX^SD3 zN{#B(qtYcoia6eS^Z}D=qwCi#d8SjZ25GBFCuy3X$AkLBye5fx5)=HXwogx|&I=#L z!oqCM8QFARm7P!yh(3l{9}so|S#;!*iph<4Ns=B#Y~H&2Z}aV_1{0e1bdvWZp=B$b zAM;G{FR?wYy-Jf#X^56=;+006x-r)n*oZMpjDE&Chy2ztHDEz8w|?(s_C%4Ub4~>4 zb!mm57HY`KJkM)F>8bn;0!$PZJq#43e@+#sA(@&I{TxNusH6sr>b@*q_0@hrw5< zdUhflrp|`bK7gAwA64k*yfR4J?Ji?F1m*XK<$v7hz0&rjmnJvZL$4+YZa&walW%Y0 z4EREc8ZAp-AyU2^x5jUZ4HQqo9G(urrGH=x!YgN#t=AtXez32nh9VWuNAl^V9@JL$ z?a&+3adTu#NZ>j@`~Vvj^A=*9)HYV+<=#JYSo;02?%iJ-9#(YI zn014q%gw>El@nikplcjWq|$vD+;3Hw7Av-dDm))w8Kg2_^+HGJz2ARJpXRWH8sA{P zi>P{qp0ndrSl0)rJ_@gK%GM)oF0w4zc!NEIOik=pps?9|cm0j5dxXQ2?C3~3G~k|l zu5_X95yb`83Y%g5y7GNWzqPB0Ryi}N_N}4aV(7WwCJx|u;e>EPv$s!Kqyy?&} z_}&p^)wd&oFP_8wn>^|_{U+f*8@`n1+QV5Gfioz0DOCePRo}L^$(dZby!P78I5x!i zfUez?iNCF_QUu{wG6NON^l0cVacus}#a!-jmHK4)~4T>jw}F z|Em{O&<=+qAP%T2D5a0e@f8|zyD~vv17nnf&sTHinYskdhv-6s2d+bJ#yO~(ggS$N z21lc~R&_D9r#px#|FMMJErG3`pvs-10iZWuBLcqp)$=iX)o?XRF*;0sD@kEkJv*44R&f%I~7 z*x-tZwEF_GNAn)eS9~PgRs%#W&u%f~URdtQ!@NQY*Y{$ zxQKdiuTIkiNkN2agl>#_{#UPul|{mdx>jYj&hd0M7!E)(^G7sm<=fVFWe34GTQbax zmE8IZR$(B;7H?R-OAQP24MKRmi008-Y)4+V0TCRZXiH(-^?YcP^lj8S)T5L>(X(*= z84r^ZtKCwU`WdF6*|M|uY=>Y7x|cYhi?jrSl4Lr)9^BP3`1LaT^pact9IX5HaDQ61$UBjDbVsin?r48OP8W4BL`c4UGv>-U z{fLjg<`qdG>|?bR-pQ)RxE51ABRd|U27TrPvrs(Zk{2(Vvx7BDtWpbt5(NZBZbq%% z`=W5eOeXW0&zS)4-D?1R%Sfny0+tPVWxvq@cmpRg=B1G`Wz!vSSm2&WZc|VQ1${@> z=9rAVxcaL7D}$s+wwD#WtOZ7b@7e4Nn!b|{uew@Jhb1hr3S3vxAaJABba$;#GVZ{< zuk$B4tNoewApvudx%zCaW6t@K7Zz0B6uU{Bq04N>?M(EWlY&^fNH^k@VF}3JB4<8lgtciSm zQXN0My16_h<)HE${=tHjk;lxHC6laCAlpCTT9n@n0wQVaRaK>Dn^ChJ!-hA9{Xheo zm~|Gx2>4u04zl2_o9?i@=}9!tGiK#d=zWB6rh}9(bqo`VE>?V1H^zzmnt@Cvmj`3s zX5-%4bwkc?DY^x8+0@| zx(p9&$&_?4x%=6OP;sS{Uf1&3%_+D{_7ebsu}5jgg97plQ%sDL=>pcw;v+cqrFpr_ zx$z_OC&by7QB#!dSy^Qs+M4HT(QE#8K`~JnN@ta#ACPO>NHpk$?zFoc@b z8j5`@8w>T^O!MVGp09D;xm7eLlS5{jG#tsRyD+a369_{T^JpwhM}roQB4k;*rJPN5 zA5?4kWP}f_G3ibJ^MR-n+lZY3QP(z8xEhoaf;ddw)D}6M4ON@KTKcHj&L{(3hX=vE zALYx=A$Ol&f||2*FYo@|d~5 zz1{yV`tyI4!~b9X&6D*Y?aN{O_Cgc>=DB|(X?@Kwg)lg_paR~VD^pcg1}QR6I@>`^ z5IOz1Y|gd_N(dR9YU;fok*83*=y5uL^~2EJ_&&GIyySSCdhMJe=cYHs7{hSQ*s`~K zNtJ*MoM=QaV$1J8(C(@G>oHAEC3pq=wj|KPvm*N>bQg_%0!R3A&%V?yap=rUn7+dP z%_8RajB5jH|J9WD*Tl$E;br*+YhKB4c<#kS0)}UDz$7t-}Ef2vx2ZIU~nb7WSD+wsiY!_#|7n@fvQi#G6RdeYKD(KS~YM7 zYCPO8@$u-ily4{0+FyJr8oxc$lGn~9!cnsB83oAX1msjdKaAE`mcVw~poKUu8m_< z^0=KbN(VTI=;o+FD­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

u~uawqO*l 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%0DZ_Gr8c?}8;qZ~i`i)fIgCb!v~W}M?L zHQsmYQ8n!+_oHgG1K$b%5)1W9Dc?OK5TJzo95D8?wpNN5?vOOaD}}w^2dgo}hA(HZ zl`DqtxEyn~EZ671lIn*#WGNf-q*B=v=208{kt>VU`HJui4jEU`qiAIy%$Gp4A%xv& z8pX~00~mFm(l1M*-3wEuVS(B`R!8|Ge>$kyUVV`#@?Ky9#?CIrlO5VRk2R;?6C5pn z^Tzq=ltpEmo}*6cndRGW--5HegLhsOISE>lNOSNwf;Sve2b%$xMfW&zX1z|h_w8PaBbc%<*Xv)l@G)AR zr=m^C@6?CfB<$`+Y6_7cwLFO-d5!2bNv;2!bcttQs%ZI{^pH#801uZ)_y7;ttGypO zq}e`>6A6yPsL~qRYOyk@tE6*L7Y7$J)_HgJsFf#6J{8&p^OD?1oiXhvv2j!K3{pdxX$^v=Mbi~x&|3|(PLZXo-jSo+R4wD&NgwJ_qSWWvm)*FcfrC@Rk#x zdUld`p73KFIRV59XOj*CYDk8*oFt3mx0dTkAtv>3Dt7QyNsESe)(Y(*6S+bVaK^)b$Ho7vr(BO@Ko~Ct% zR>@v^zx%Sgn3+Wfc6^)$)Ef^Ec2MaE)C6qEZD^1u&e*2ZH-jxo$w?`+M%@dA z&38#8ci7f(2J*mCcLpK{Zi9L3zsTW!{MMPD_Pqb3;WsAIB#h_2|KU%7p zAV(ZY1ESnW!x53jI8f_XM2B`=fHc7*`Qgr}JHh-8_3xU7Gc1eFsfctRvOdh5*q#1S zSyGnC{J-Cf7_1hKD9GfW!$Ybw*$maoP{P%kK@7sX(kE+r%4v>q(kt~xe>#kqRIdK$ zX_x6W4zl>~D*$+Lf@uNL&}-xrrl<>!7QB5CEbwV&C@p4%1(a13?e!ZnLBv-{ex&B! z**4SqhRSPcO)owbYQ`bu)9x}0pFAY09?>8?CO{T7!oS&#e_(_@wjKv77)Rs*PSHat z0@d7x5Bj`jM~k54CK=zNo(>Q4<8;1E^y3KPGnW^es#49k5%v)lh|EREBgox?ZkHfD z-UaBp!B`A?JfKz#gnPnLPI)t3tN@O}qcamVs zIVvMH!aUGzMML|cjoxR*AFCPJaXMn5v5!BTj(wEQ6L9jTdn!PH8M23zj93L&Z9n!g zT337xz+YE&Nc-P=<12mm`r_kvN9j z71x1)V;qJt_8w&Cg3*mWBeUU!>Qq1Ig%hXN$4|zE$`$sqH((#R#)0Dh+sHXF2DZD9 zJw!)J0_`C-PQ>o6AD$boDZOXc_j9#eikqLaZ6STn)NL7ss0j1BOn@AVM<8H( zxC0=KW;vQkS)nPVUu*&jx)Oi1uH((p)?pQE)IJ0I!FO*D9V(AqwjaEFPZMk&7CVq* zK!YXQ3h8E)5IcuEaPz|Y}sMmAtF!R8lz&NQfV7{6_* zUtLU0Jrle;Jw0OgeJ&AR{OS}Kw?=bphbw?Pc|qW8^rAGGS-Z8*?4uf_lq}cv%bAU5 z9=_6{Rw;R+g#WO3G%RQ+;&|)-ntLkTxdv%%G3DL_g4N<@RR-e|W_X|%ZnOM&X>*DC zg=9Q%dF7wZSvXk89csALCFkL6oEX)IS)wko1!8HYbt!sr#`d(fwoF&~(J20mCv{PTGXO#O~gRQS$+qB*El>M%5MWBzx5Cz-=)Doz%6>Fjlx)(88Lli zu~_x{U4?iUo7yNm%a4ee;Tzl#M0{fR53IZ#UTy50W?#DS*600}(Ixk>!rO?dt$5u= z6jhpdZ0rqUvPRqO#Ji@3>>EYddKt8a+EEU(D{(C_0V;R!2nVwnh~E+j+|zYA|HcW! zgpD7i&7Rtv9bCfnqUxlTTy^TuUWoT`H01X>tgI{YK6XR8Gb%FJ?$9ws`tORmK-2s_^a3 zNL`!ttSEz!6t5^_|8k#qab+78E1H$5pyR@D-`SNO8664-{2b0dqT|TCAi5&s1xI zwE$K>wBKA7j0JqlD3I~__MPza(jVCLDE1G`5zvRo0H@W^^bhQI5&K)|*7m?P4o27<+Mp-57q5m7fE3s<{zd8wuy3yM0vmJ`TF-Ltx$|#4hjQ zWB}z7)6DKcZrn!h02h2ejDt4e)GgA46+79AjATp8q5i;>@D#wD!k)&>t`RCTSqhmg z1g4H2iGAGl7qIcNOK>m+!>i2u*z4RfAGrrN{G6xcAH1)7fBK_)u=-`Vtsf+b|9%lD zCFm;{U<#Sf6-DR<@BN9ZkjH_> zPy?)*l7uQGE0rVw3LYhs%?|)s$2`Itk?;uH4_Xh61Lt~6kJf0RxV}qoGix6oNwzAe zsY?w>x;(%4x-2Y*L!a4!u3`%dv&!_5lD>{kXPHNb?lfe7@TjWr=bF}kzJI>J?6k6u zy@gBrj=-D^4wjwc`V=r-LO7xlFhIg$IO0XD3i?MYm=obe<7_2}l&b(Ud%88t^fO*z zk8-@!L-J|a1E0!djV@n{iskYN&g4#0h0YVqCbMXxRwlHajPK1v=$BDwb9S#U2hoS zdfa4v&fy(n`;osKWY zYh&R*W)$ig%txw!+>>5Ob0Oxw5VKu$6Me3r>Yv$xjNGOt zxXqH{5YpKf#_uFa+kH4-?WWk#ly#DOu{Tx+zEYUzrGe0wIxQb3hL`{-1Sk!NU!)Os zOfGn1Y#rN2lAp9t%y*NH(%;ePAb8g8XCjYqW(fQQ7bjxyawZRT#z;%LE4Su`x#I6ef)kl556zD zTT{P<4a!GUwDhVP&Kt$sM&mqBMZ`72!-_k6-Yjin)9CzfZan!No}V4=@3CLTT_l7| z_2%5Z^Y{GBfi^0~mAE;ZDKLxr3hnf4f7_Iii+RW7lMqL`!NvZ0*853UVhA-~G(~?% z>XLxHRb^z)uItQVTw5N&m$Yp_O3sB&FC&?rggQCUq)uQ90{s)|#q?!8!Kz^FpcI-> z%l3F13x0LzC6%4)hM(-cUgGj8O>J%8l*Rq_eR+bbNuRy|3VZxdKz|3A)W94IP@Uu| zhiHrw*l81a=nXJRAXbdyL>z=(R@&0sS-4gaLXW`k7a# zg`rN^jM5DU{jlqI?AmvniflW=HCIs{3DoC4wkD1!itw|E&K0T2 z6`IEO6C^j|s+~V*%P~x;ecuX;X8ezcoeY%lP)~~4{ba+KJTF`UX3ByaQ(au$4Z6(t zq1p(Fpn(yQ2y%=UXXk(Uf(A=Ulggq+vWH*TbqoLY!6>~?L!32cJKpSf1O-&Pzkq$L zC{TYG1bJaCk@T*Al~o4xh&j+Bmeo|n!o1NM!5h7dQ`SzDpPy%F0)8LPTV(qr>v~0IuPDp4UyaOeH4xJTF zHV&V~!~G7UQ_hYW@6C7dlX|^t`mscrfLG58-hG4bc_Mm0$X+3oI)Ni!1L*?cJHRS4 zF~HalVD_S@aEjN%`SF0Ek>zGp>cY>=LZ6;6pYj0p*!_X8BD-YWl8Vb0fZyU>dqBVD z$-!Dfv-l##Lp%35{YA1<0X=8vYAT6p)BnYpV%RCOVNa9nimcwqA)WVWeDWdd3Z*XA z=-Q+E-yCkwQAXWLD&g*jLiyC_?;??5H*fj5pe$TQ=}gP(D_c4ykh5=C|Ah)^B~ZK35fq&#dxe~Xr0dNcxo#YOXvC`j#^HG7=bR_^Dt56ZV2s|m-|n)&C1XM3-@#b`Yw1^oPMId_e7 zP3|w-cWevdrp<3Q|E8#`VtF-?fl-%5bfQ@Eurn^s^25+FCin!$za{waP>4^Gj9OY#^{T;H}O$IF9I84Uf%%L(dAn)1cB8q(_rnd8Y70 z*_!`PCwq%eM~z$rtbcJv$^>Sy&gKa(@UVHqz$1IJUc=dZ_d&myt1^U`OW}Y}Eq7Pz z;x_%AJMQX_rFdtIRpwU*HV&p3gc{kap0Lz@ziJwa%=?EW2A&RZmlMG-H+!g%41d_X z2a20TNPB05+#*D8ENwa`nfP6Pa9jP;h27qD?uQKc4j3COc)$lYv43GN;JyR#9bkTZ z(P}!1nlP)AIoou++C_(CI~si4wSTpfRc>qSBNy<$QTOiAP`2&=@Q9LZrWCRrqJ*Su z3mI+flT!9E6=gFaNeFY$=Y4JkI0z9LMqbToQS^cJkn3iC{qRd3h_YnmTq2F@zu){FJ|t4yE8la?koOG>5oOT-DiQtg3H6_i@SXbn1wwd zXP#ewM$l+~oXeAAM^al}+BZwznRZrVumRfvb-mCjd`1ZfXND-rOWVmAyGE3v8dT3_ zw`PvGDf@qSF?aZ35-@ALLwhNT`|zb;>pX1`J{ac~%_#k+D;iqDFhx_rhx*ZcnF5J) zSDO8LTv?EMdz%DX|K!K|hQ8#^@&SGye`%k`vRyZKU3v2K4q6N}h{fy!^nYF5U`h_G zX)r6Z&K`WoMcR4`dMEQREx855#XN_1H#2RZg0`Z3#j4mrZr^{!fSn=-?lkj!$hK*? zMilxNa3X^YX6)TcxCtZl0JuE6`-6SEOL9tkgp%q4xA`LH%Tq1I0FSJjySS#^TJ15l3Qg zgAMEr{w^9B(3Fi+eca7&$2>KpjrH^uofiodksZES7rdul@RHQw7H8hDFu>5C(`dGR zEKmbhqhR#6%0Co*%rxg_`;}XRii$Olft{UL8@tfR?8t z^&fO3`xMJ%!biN%l%6ZuF5@G8UuDVe+5hAGo{$GMreju;ml+9ao-95R@%?+m0wQ;# zH%!)99U_%Tq&sfF0wpm07fKZJ#Dro?&7!XNfOy0n#*^(G{OW!!mM`<$%&qgKX>cUz z-8e;ah@Y(8P$hEDTU|^mwIg-O^J^-aKtCtQ29b(~az^vNy;n=O3 zC|8@7WF^8#@n4OFIc|2fGU_%*Q&i(d}M zmWzy6Hshikw$tD~LpvgDP~PQkve>esv2k?7dl}cH7jzm}q#ek|aKRH)3rz54PWV=G zS%7P+u?z_)eq@rbFj7@eDCNVk!!q`a?n$=iqVGWnGqeTuBFbR5le;(0Pb`6Ja#TV_ znsK5-!x-uvXXZOkdgxXj3_v$ZQOqy)bq2!Y_!lbm?O&*BfWdlOFj%-?EY%Ui91~)7 zW-wBUCg%T*AO9DQ{6GC0LH*jgjbb9Xr)cA>Yy{|~8H85f5>4~}$RfNVK;=Q%&lzd6 z2fNl-VHIe?i$mnK?hXCO<~W92MaHJvfWk&h(OegTz6##!5(t3Jl2q5fQ11%Ni7!b1 zz8!`2OoYc12&hic0cKK!K|{a38WXoK{L{<9*f++1Pi+6+&34fM3Jrbanyfjo#Wg>b zot9R&o}8S4E{Cs$?*EctD(zaycInF9FGeV~jzh$VTjozJLU{Ogp2}w|(afS%0o$H4 zhze2}rxXl&mTuNJ)Ym@0URfHdsw7jQwOc^oO7RU{6hEFSI|+vKGY~G5yuiye#nzud z62q_N_mo9VLeO2HrKKBY7YwS#_=Lod{y)Wy+5k(EOnoFjiwZS`{u5{A{yZ5R8>{wC3ZNrrcFp(nVo3wOxOx z%r3m9xS`@Z#i-yWuI86lGhbUnEC4PP(%@>EXEVgsAdF8}rbQGxQ?_)oJ-#llDe5m( zG`sHNDV6y79mORR%qFu7^((jNGae)or!?pmr41?j0b#=Y{D~|lo44P~-hj^Z9T!Q_ z73fRVryHl)TZ7DibEpUFu)^d5KTF^o2kLtBAk8Y0dHHqI(TQ_3y|nu5rKJj8{3DLN zUlR16bQb)HFzMeDb>Hgm`F`uwa zzdAb~2wc-YEiesjyO&?!JLMa;xaeM}q;%9L{{$s;=a&TIwtG$QV~un|iJ3%L9uTd! zBU~M1^oT}_ga}*?$OvMvyX%yrvhkX;@f3Fr^N4SLF9$w21f=E)&pw>FDqoVxA6}XH z){FQtk|}71a7B4BY~Z64jS$MC0S&)jp_1kZM+UW}vu$i#FMoa#HEZVSuNv>EI1@Qs zZW8(>nmZUh8m%zh4a>_i1tZBsCcnu!Dl>!sxAy7x3l6eRLG$4+39*WZg)0f$KL@-s zdKaM@ZoPf9ciWdcZz?J#Vz85Hp2N<_Lkx)>Jj@3Mpxf*h``TtSz{_so09O%IA@qsJ z$25ck3hdypCLOs7hH1>SZr+K54jPv`jYRXMGf|ztI&~$7`~iEd)eLb7pjn*0NGGwh zy{&tX-@$v!(GDj)dB;TM%8Phh?+b~Tj=tx7A(<5L!$HqrG`xx9#=|%F6!$gRf7PWYC;K$_>7i;voAFGZi*l{!3Zd!jBv_iiRSxS zR7hos!h>X{05`(jO_fEsG$!jgn~A$qTOFim7wWlc0gKMy{dM0Y%hB+vS7zFBB3cUa z%$XPY*Y9wlFs_$yPd6c^L@Gmme4>#joeN%b73-B(UHN5e6TABY_eWE`RQ5bl7Wod> zB+;UQKhTCc(GEpCz&xqLe6WGTnk8gPK?9O$zdPoh#+|Cpl6AUd@M%z1IS{NVe>hjo z)O_Mx-%%wc*@(e!-wvhtXWxl$isn{1u8l&;B!1t8N{?oy!&^B?JfRcIN03_31)(;8#79Nfwf*Hd1WUfCm5{rtDuio#&VfC{~!Lad);H?uVzH-)gq!;(m zxiM}8eWiD~K`!t(;I{NsHB9H`S}Rc2%2RZ_D;zHzaVFvQ)H_Io%#;N-<<5E@$UT{+ zVFwBe;G_yqArxee=>^>M5e(azKyvw^srkys5*6pAi-`kLV^-m7r?AM*a>ky5wzME_ zS55u2?bL*^{$V3llw0k=&l{q5KQ>>R2AY7Jct#)mm1@w0DKl%XUFPyzXnufwtgVu_ z#ISt%-nO8%jA5-jv)>V{M$tBa9!nvgp;Zrvi%30<2BX4FMCwiT=RV-j=lBK3dz~t8 zIfe^7onqoIp`3Ro)2!soXEPpl1v%+A=9C6EOQ+){s5Z^{+z(=M8Vh;&mE3~bqjY-&5M z5)PX(<9Bh%w1l`^>Av@C>>Fj(OFF!KcHFwic2l>}cl!}B2(Nh1C)RF@{>4>*nXEq3#@xFlHe`50BrtPR&Ol33oh<~A_^7UvVHG8>C z{oB`6j+hH@Og|cptMQr#k?wZzXhSa~d&14oUX4=_h2wiQ!0?|FKmyYU&L1*RTL%;Q z*R%ZkK=xOP4RwUg-n*6_&C7=!{^*zWdamvC5<9~Z9CuA{v3Pky=A_Gy+?%J5-28++ zt}hDooHM{#dp0sjtAtN;;!_+?6G@u6EtFQN*ZRdC>d>= z$ZxF6@-T)Hw**mAc||89gPBTGUzJR18188m<=2vp+|_%-`|fNQSxQb7krmn@%4etq z!xGJC@?`ElivYHGAqHSSz7mJ<^*P76;d^zH)kK8Bj-Crug8J6yO@~Bhum}8^m zU0&PtFll_^M-aB}oe&4y;;y6BZ)f(In%@ zKBg-}z<@Ea*)7Xhz146C)Ohxu0p*S7D6ryT5~}&xa2(?dP81(<^H6H0ZsiV@8P!m` zio>Gs$iha#pE)GfSiDv(8Ps#wkyN@ptBAm{V2LX=h>(JPbKv`MLjgsuEI&wjvt#C4 zS8_C$OiX67D``RcZuW)Vof5pDI*8^12F=5?9ja5nzhhd`zG(7}01NVf@y30>Rnzp6 z*-}V0MQ{D3d7ty&-o@~ha*|(U+swmtYz<8HW!nQv7pJg(yEy&~gQPu^b; zoWSpsjbIDm6yMoO=tCNTgptzX00ScQq*yhv_(mp$LId?Cw>$OTqoe z4%UytX^jWj!z~YAIe!0~XEEfe^aG-BE=Am{fU~I+jmRnYy?!6v@usnJ1?*FPXPPC& zGvnM)h`_>nEy7(%F*WZ(cnPc+N~&o;P>%NGPF)S~wFDLyP56x+4^__4l4t(dc)^#o zi*YYIW;VIM%q7EnQrUv%m1S|=P0V54!OeHvoA;}bWmu?op zMj#k?Z?pQJ@jAhH1IL^>8gK7cg?knkHB^rOVv`|XBfi4-iNOx>_Z`bpoXO= zzi=LG^Wi{md@BC2PhI~J=aB3tuhUg>Yzx=A$L%|trJv-9=TmC!Y&;7cBlb_HxW16_ zdYj~Y;|_{F!Dwn5G0ziLlw}Gi(09P%4AWs)V``|$Uq>xa_xtT_qd=Oai}w;!C*gYU zxW9+THPT|%i5TCfS%M?g-hX@-*MXM-sdK0$xDl~HIrT(_@;ni}c4F;>X;G9Kn!|Mu z=~se>sE3n_=gsGr__&;69*Ipwqgd6fT;E;g_cP|xO`q$H=2^Q9I7Es847ALY0O5$7 zITg!F7}NR-b)lx7aAs&>=$~Hx&N-jV6b2E!9Dr%7r7c0_a|vkJ7R^{8JXDZ#5~0!f zeCXHx0hYzZ9cFRZX@f!HLL0;q9ANSct$&^B-q^ENv`GWsr&kWKtNxqjz^VpnRDxy> z0zXP*J(~b$;M^do?r41O|#A5mOiGE)K^ONYP1USNJm9oU4aZ8mhg?B6$JwQRuLKk*kzx_t>eUI!fsFyxf- zoWMQwkFTSkGtXBtm_jPZC3c1$^dlbn3-#J+;P0FR8 z&a5}D{ZcPf_4;UCR%oTC+Dy7+$2}168I5h0Kg%<{+rQ{j2H?kWbzWS`2|PXIzC^qo zB-)%r-wbc@a@Bkya&IWbmTNz^|EuvfTf_FxUmVYuB$huwp&tTY30%g;g-X!f@s0>u zrjcyi&jgaJ`$I-yl9T6;T}AV*qSa}h7ZClqPN!*BOT`DUZ?o;k5A&% zro*$MI36B|c(&yHqX$XzV@yScBKyYeM&Qgg2_foOYp<~6V~c@nrT)*v1TRU;T|*1+ zW9z?k70emBDMb*D4JqFHDmy6~;$v#O)?!IdN@0EY^X=!`w@1QXFxc!39@jZ%GO^UP zwt^xzl1Ee)Y3a|h=Wlb;xcC(pCN`Bd z*KzP|=plC1LPr#Ub0%P;w99w5L!s%stTL=KX1AYoOMp)|JLuOpEolDXARuJfci1UO zE))B^=A(>+W>_d2Q_5-{9E!q~VE1|U6Bjdp%<*MM+_ed)F?M`!68#pCZ_qx?TdTD{ z6WZAz<1zazo1lZNSolY?hgL=7TbZ#=1B*3^8w9?5a6ZQ^S;{O~6|2mr2R zY7W9SM`V6{jqV}BYM_tD3kcsfluxiu;jDi7_+u7NGo<~b=_poWoh+63so+k-UTRwE zi$c$%b#q744P1X#$9J?jB^k*`-FaNQOF)n54Bro?72F1SLw6n#v(E0x04q?~GlrF+vkm!y?1e)xOXwV? zbAKJ@ofPCHg{4X&ZTMEKw9(h^zBrm4pBZtnsC3r{#QKKSU2HAX=nat{PVB&vGod|! zDzv?=69N?Y_++i!rA*WJ4%#ZV3+f&9laZb2e$Sm^(!Nc5oZbD2yqk@gIz$f#o@*a) zL?@AMEdB5Zkkd88C2C@sBF>Cc-T9<8Fe}&|vAkzi5AUaVZy@Q8;l@5z{m1s#T4u6u z=5TemZ4KP82K_keRUMX`!MF4sh|U5TDLXq~V%-r%_c!KWWl9Z$*j0{8@;zV+W}C1Q zW*9&nb?_ggb?hv7W7dbFzwJ_m_oe-HCuyaW)g{MVS5V6xulH2TylU-mgud=2-ea79 z#5A_H|M~6Eon(+f?8N_lT$)fE2;!MAX@UftIJu)Z*Uxz>GD=&8!sx0@X*No2j-O~q z+bj0rkoVmP(|UFRj$oC9+7^w#NeB~YF#XQ=faG` z%&L8yTqm4*g$-j~o_Te6)QGnYg|+w>BK}@NmS&kuj@j|kVz69+SK6fX;*d~sC6}is zN(N;=8h$B(Q#Mba8ESU?mz;ba6`V%9?0}rSt{5l4nKG_th*MU4L=|47$el6W%N}8HO?dyFSE0NFZqP8w ztH??RmbTw*kcteGBw~0p67^Gp)*4a|4n#*vo>d4c*Dws5aV%Cot!4EZwSh)>L5lZm zzz>6bdN71M?Qqzt&Ve<5R*&@H=-Ts`&0|BW+kuIk13rljuvZ5YPPqXkOl=if^vINR z^y&!qqwAj^b-V3;ackCec8k}yG&m(l$~Ro!^;&0Ng|OF_z7o|nqq%6Li(W|MY_%7f zyj4q`{nOe_AH!cTm2vw%I&oa*X3OnAZ9S12Msc%jl>$;ZP$JxjjMZ+0V+X_6f-7!AGC2DS3qP3 zQ^XnDnVjHJRix6dSGr$zfW0T(wHtEs404sn+>({cyjcw=_dPp1n`M3=_jqvht}4YU zc9^&|>!h3mJjB#8>mC1yZs|{(cd){ zVrfN7d%*6bR8*00kLrBLOYG)XYYyAs4{ zLt#Ek7tXl~_FWh(D9pKeDM?ED?hp37_I+%_m#ovZ;bcm8c*?m4kx|UkZ~g8zxrW#I zYG$um-l@yB3FV9rK4M`_7NZy~;z5xmq74RHUhgu6i0rGtG4BTJ@PSQQ1I_Rt2S8aW z!vF+jv&6(s1l%1t6I09?LL|S*D8WYS2nU|2s_I)6HWKLuE%w$c6l(oLT zF|kwsG?-$D0}Z^dkZ$5?pagJdpH682lAC>Oj{lx)2chM^N852f02Tm`EJ1i$qF>8heQWfKiP%%rr41It1T4}__vjQBA~c1({AM4Fl_8R&p%rkj z4|gG*J3yqEEr%m55kRf3QJzR9VS_QHR!fgx2kMuzULPT-xs3xuifTxH>-vt=Z)@K+ zH{)>L{dcx+>>=$#0wyIM2TvQYQb3(SKAkwpjo#%_X`7T6*DFtV zINz7DFt~eRJL+nYG>b@_ZqZSPeuM+eYOdiyKUj?R|GpSCisoHS#4@uccM~&w47LS9 z10ff$oU8TEf4=mD+~w<34t(XlCt34bpF@ls3i@y4iikw=5N<1k>@EcLh^+|HKhO-U zs0rk3kDb)7-(Iv7o(Ve)il4dn*r5M<-@Dr0r-ojh3gy!`vz`QgXj7HR(=ssLOh$H= z0>fSj%^s|(e$@VGU{C1Gg@F^7(u6kRe`qxZqm^69ek%JrCS2Y%nMrd;7kFlv_>*hC>Xh`b5{apU9=klZEBO>FuM zEUX9;9f<$*axnG^@jq+lkUW^8i)3aqkm(pZK&4YrH2*$Wd5g2;fDY($aR9lIRxL5s^EtHl7Q&Sj?!{c}Yb>BN%j zfyRY6kS?rb?7Rx2n@P_LQ>W1A`R{OC^RnM!QsP*vSn>Xw@?5)UX7Uk@q*gm174TE! zFBHO?%BuWO^e&vRV_rys=SiqbIZL+d8GB4u%`Y*DOi3|`&|sb2ZyP`s^2inIf!>td ztVGbOafnGKB9+Ce!u|@IpMbHw*o~vXIs*Tv?<*RQgO3vtgE*22QWtm%MBi%iFz0`M zAn`tMOgLCt<1vx^92{*o3z&vgC1x0=aZ>-7YlW3Wpr%Fk$EVGEPi)xU=rgu1>QqL_ z3QJ;g1pbFpXEVkFxbiX;y?C6K>Cod@SXS?>M?F*xT@P@Vip85eD%%)8c_~u9bN<85 zU%Ew<$Cvwm`_vmxuB=Sr^Si2TU4Opp>QE^@`0~Yyb%K#>f+^&iQMTSB2PQp-^CvOE zOUsUZ9@_4dG!xzE6~ix4ZbOzOx-#-RkL>G6U=wb)MNI2#fcdr(MjS+Fmf1OWfx=aT z1*?oa_qY8=%2_IlL!V=>mjHU0i5k<&uYEn zrM}e+0TSNQ_Uo&?C1>-R1>*v|#k?g?_}ILOKArv~IC=`4gVPg_M1IDAfeeg@qEC$7 z#r}diY;I%DT>RAwy$8dq%0gN$ZM&0K{e$f5i1ULQYp_&ZG)aqo4SY=^>^ZsKTEx{% zU2Z1wEh%M1bJsHLx?=7IbcyC4jFxgg62fuATe>!W-9p#BB?mZ)?LWa~KES$ekqc1G zJ;ejh!#Hx)RzvUPzMlE0gGrgL&WDl@G>a*6h4L%>iImIopaWsIC{$L=l*!dSv)vJY3k>Mu#w z3@WYFYudT1A0W7nV7^lh*mA$P-&DYEDqai6LEM^IpH|(vt49NS1V+J0p7Gl+#3wv8!6NV;Vt{s z3a2c#Q_Q&=H8^C zM&h27HjE&gXkSe;olUzviCgq6JMGR->Nt>4Vw~^6{p{e`lNYUz&Y~DiTW)C*k8mr{ z*ssE%%(C5%^|r8l$?oFdQB~jvYUt1L2S=9_>`1orDvh4ooS4P!`&^Nqy>5r+{N7@?sZXyzupoubpG+}e$d$-5<-6#2S9Cr-YIfmxCnF2sTw1Snd4GxX% zd*I5+iO6v_+@o}UIAk)&pfyP|Eh6uxi=-W++Qali@u-<-)B)vvGTT0E`zU?T>KoPx z3_h-Fw@gy+)=Ixo&yx0Fpn^r(%{Eu+b`}{8D>D(?9 z`W>+8b@U?bgw_b`Tz)U8vLyM1A^hMRyAEbRf-20)gCY_B zu$y#9MXQcjg8cL#{Osx_X2>J&v%rQr#5BZG-OYi=9}z(_k7QRM0~1M%J+;UXGQhyR z970}fRJ9RqJ4Q3He{yk?Uy0f!U@USYin#80C6-AEDDg>HaGXvlY{w5)Fwo6>ybi;*_!RImVR zAJmu+$W6Lquv9aw2+;Eip@r#JQiIx~%6^rJ@^bxtQ(Nm}WO=LFIb>YzY|I;9L0KVuXJ z!q(fGd31H$!IMx0V55#0=%0n0?F!f}`&9#C&H^detT7{3t#de>fN?}PiT52TOdVgyp4Z7yk@PCeawZltfn^(fV zOCXPW3I4>L=+?~zHJ)qW2L>t>JVcpz*nojEF3@h$b8_91tZw*xtc{qqZ56LuIhk;I zzpmk~*QNaT!#&> zRIBZ3O>&IVs=bOs4Zi(Pc>u0to`1jd?b&mRo?P<79BtKexU>ok_4dS4eLxV8B30vJ zg-zBK6_cVlovXU9JlreKk$p~ZkngE6?7{xq3!bSg@V2$!WMKjQ)bu*pJQvbLi`hf$ zwE<@E`Q7*xK*4Ts@PqP6Ht~l3`>HQ2T|V6isXWZN(u(^IFv|VwG;aIB24TYYapAgw zHSQwxFAWm!Y-)~)mRkM8A%)z>kx^Vt!rSY#il1!Y)1PmEz>`@C8)^e+yyM=@>Ex<$ip(BY;7iPr~_+4rU1-aI9I#M(>;6I;U=-`E;#~hB! zhehscd5S*cjr-~j*vQir+8Y1{;c`?t7rNH#iSB5FRhm{N%}`t zj-NbYw8cd-v<4g-hZk6$V}a*cuK-C9PDF>Bsaw|Kra{s#5zP>b)oHm#(@M`5ua#9VFJZ&#uyv=ZZG=K57 zlZVCaJx|g5*ybRrOJxhMcXI~STAcmt$ANP0QdD252iGc%yMa!zwX-TAcKiiHQ z@qH(5=_ToAt&Rub*EfkE*CNm3U>PgWgf52;CkO(mZoZfJ$sjul17~CZ3+oy^I6tq@ zR(ZfB>t!!?EERfb6C$<3CC$OU;ajwD7U5Oja%`u;1J!~(OufPJ>aUuy+wFmSs{Oa# z^70QJ=~oZiy*)Y={!0ZHhNbb!E&*SC4g-p8jYvG;J@a8nLXF$7*P#7S;|YkI+8_c4 zOm%#)Yqb&@vYlBg&-XMvS!i52{5UM~9(Tme4ezJQI}ABVTZC>9AE4@w1L0Il_XudQ zRL`3f2Zw4o8a}^w{Pn_ZKwZ)4f6ebfuFO# zfE99msyOg!iRbOfqnWYiMzh2}JOOTks|Wy~Zvi5|zT~d4R7{h;LN#mdp>Z}LGDAgb zUA&HFK;PN%UQOE1l@zV0p22eyWz^Gcr%z52xB5L1-WRMw8CHpo_=H~{?;;?5aIKJzw-R?hU)rb>g zlFg{Agnj*GCkn}>^`*y0$Hor+aGf?BKg2kFhJA;n#ffXvIWq>#vqp?NP!Hz-kqTu& zEA}YD6Ql=IhK^N&$X~i6AFKj(RF0I{0x!YH0K!nOXlnkx!6L<^vFUHKPtNZ1T9WTub#k950#HxmX6VtaHS^iN@ z*Vy;*oQasCVg55q3C5m*$e%$xF|+=t;S_w!-R@#)b4^fsVy^96x0YIYeM^bGOu}2| zU0;e{%lMEKsEL#47DeyL_E$6F)-DO(u@Z8`7uIlLd`JGD+w^TZ0j?#i>Z_7u>awk3^ z6AYQR!DF^1-j0ia1lE=~o400KDO%rGM=GM5TSPpHaz;knK2pssVgnu~DW7JGPoBB< zNf31$wPFTK34z#?0%$5n$UG?$1-Y(D`F?H9#`Wr@WjK6U`psAu4Cic2|I;f}J;=gzPXLTGno;P*o|AU@4AzBj)NxVQOM`=lm)^zKb@1)5 zb9*0gQf9m7ToaIke+(}5D>#_bxxs>5_tS4T1T$yqeJ$zOvc7Wbf*hBjixq-GfBcn$ z2~K|wMA6fgqXVoKvI!5sH+K@LrrnP))XV4}JKCEep22VHtpr}0jE-z&@6Y(_?l&DP zsw?q+(>M0W9Qb)RS<1*Xs}K~CrqziAB6)D)338+lOODSdU$KK9TSo<=-;Lq?f-L8X zGcS-VM&B8pWQ_<*>vv8)-$B#@Q?mc*>lf$%IpPA|*$K{ONI{M2$O%7<$pC9Vt5%#? z!O`2-`+N(gQ+A1JJwBIkKkAFems=C%`zg8`C59K#GYd9@p4tZaV}KKPpmk|&wcu21 z%Zl*9bbXo9QVA1&Bbhb8A-Tnv^+@yQRy(=Z5h7ff}2+xht@hzFdv!>{Vgm+H$@8Erek z7O`g8D2$@vfUPvvfOJb2Lpn>p4i+H^wwwIf8n?3@wDm%0MbBBo!ZmJ?wz=VJxwq0b z&m$q*{AcxjrGLK<_T_(#*#k9x#qCz>2EbLL3*or+JgMSM?l63RwzJtJbn>Os;uYETYFl5^sB^VU;!mj zGD4CZOVHaIRN2vhkQ`KLv+-H;xSi9~wv1f+_U+#aphDLYJJ+6Kws-M2s{DnoI8pW` z&go(XWf#W^0C@;d2;s}ly=v+olwTF*t!6$UupasW1Xr49{Rb1Fe?yRWba z_q2SAQtO}oM;Y5@Hn?g(s1ry0Z8$(&)pA4L=WlGqGuqf0rHOSJFh(n04q04bOZ<-} z4mH-MTg|-(4awfFN0*Pz9}1@p@$AHYBR-oLG1)2`<`U3>fm`~K;mEGINvn`sHA(KG z<|PA5fV|>I@$uF>$;xd_7UpaU)fYymol)#7^k_!@fBL$(f68ZI%m4Ft{xi(?d*UBX z*|iDg9%qC%8U~#Ygi#4jIGk40>=md*jcX3NHPXWEsh;EM6O?anr}_1EJh#7$#gp^7 z_Guwa0=s;jP>}H!SurFG{e9?U0hGm0s34a$7^k5hU7)x~9co);W3a$;KmOkU!Jts18(t4xOW5a61ZGn zP^SgB$0Ayzv1-k!oCcCuwfvEd)rDlkaXM*+*^)Bqp?IK3ei#*s=D3#eBjw+tC;zG! zf=F3m1`8i`X1?X5M}zVQh^+oX=@#WHkkRl-75Zl?W^n*Gw=y`5Lgb(S+#mxdzWqvJ zaT-4y+zT-2q#BqIDSSygUr@tGMgMrskd;et7et-mf#q6-R7I9VZ@DapXEmjFP8L zHWp2TcmZ_&c`nsYVg=l6!*kkXzAt$eW>-aCD517J9V9)_O0qtnOsWgtz5BhBZ{Xq4 zFL%w>PB8-gtjuXciwd-yvpu(umYX`-P$jAz9dF|IBn5QL^!+rPjNv@8_+=kkhs-Wy z{~E8(lo2sya9-H`cR`lRyXY&2X_~K;KWYQwjj-NetS=b5hJf55J&yGrB4;^>!B|>A zs~~ZYHkQWMyQvlMI7YeKtonOSX>oNue~bI|Cx#_Ec{~!>*W;fj;}!m3sSX(UL?%6z zl>t0^mct3yk(%F12MzznsdVerPf0ZvNpIBirO%3e^->r+Ep?gk_03*@jmgkfK!NxT zS=_r70^_6M4+>vq&1-BrhcBtsjycS8-%k)%=28t9ahZM@+~U?}y6;+ra_?cWlxZw^ zI%C^{121syUzu)I17!tgO)H1g4lAwB9lho4Ia>Em&^YCU!43G z5E6k44$qt?b+;M@+GowJyQ<3pc9gI+bUHz1Wv zfr}X12whl?REokJB1BVa^D9&njk#;+*pR9h(W@nFyZ^vTkwdPwRJjB_Epv4?-xK$c zYk?}1G=sME1I4|nk45XvuJq@C{K3TfN$J(jY5BME)1~~nD63^5ri3Du*b)yAkdmVx zNP-_!i9A2t9^5MOrCY7s>&jcYmvKlqb$`snV&9TwqmIeco7d=IaCFOVkrq5IpFsFY zn7>d`R!b0b?*zi_O_QEnXNrQUIZ=8v%_IKp0&X|L&#E=_*JYd{J~~tpIxtn6qKeZ0 zO3xmTr&CW&k0|5THm*B=^RVq)jWS4fX18CYR3I^p}}96+i9Dx8n{v zrTt4tIN{Xy^BPC>Z$M{tF&UK;AkSRt@DEb$pf%vHJRcqe0t0V1yta9l``QYA`lW0% zm4IKZMR+5@O3eiZvTCvT4E{h_xV?zvH#-QSyC=DMZB2Csn-(tM{Uu}GaJ%pG>JZ&% zEa|E?UXvt4TFUz0KX-!v?sV$!)QKQoC6d2PY+JAex?+Iu(Lt)Rx16g?QQ_12U~)>R z?pJrb>X2Z>;qmkCmR@4vr%!5$0_)C={$D)1I@aJS`jC-v;2A>igTq~&ARTB;eIAjK z>z8;sws+`)$>@(%y8Uy zPo@}+Y>%JBF!vZtvLeE=-9%bfWsMsG0#1my3hq=hv3d9Gedzsmey!qP0!G@q5G)Xf z0FENe27u2%7XV~8z02bg3IuWSniLk6`cZ`m>-gV65I+G$h*tY#r?UVC0a#fYp&kK7G zZI_7`9DQijx#P|kF*$uk4N(pv)8fE>QvuJ-*mYe*KQY%02+q@H)84z?wf)pvnBk_V zL{aIyA|4-gD2iJQ<#cKEBIjfr^V}T68|t}V3tG1A&H}Vho>c~Jz!oAG5J6KMKrVOa z9Qp-x)sKGgS>6+@DxZQW-5G9gJMI{se2t>4sL^;-R-Qb*Xd0{gpx7q9!T z^NrvF*8Lkqy+Vw_QO{=#W1Cb06;j%bXa+63H|*7Xs>!jlrUyKiX|UzS+a9ghp?aM5Mche$hkHKZW<1J52w=fKK_dWZ}otC+(NyI?pE+SY6VEmyTehU`|R140U71PkM zKkNHst3op4E{5E`J*nJc*!(&{!?aoYE*NmRmiupI`=WG+oDV_-!D7ZXa0%KChXb!t z=`XK;z^9}8ZX`4fZoIHl?Yr81J5KVdio9^xa}L4b%DX@BY>G4}fpv=nh8GCTEmS$s zw>J_E6@|gPYv5Z6?XE%C`c-f^^ z8Bh8f$s2D5;v0{OpM$>6O%iVcPE`yWJ{h83Qi^go9B0(;L>x+up(#KkROM~5(^$Z!nQ?qhmSEkKVP1cu6M?J*a7 zipoqiTMx$0Iz@i5DJYnVx7YaXV4QI5^o|Ar@TlT}alk$dDDYOF&yvE$9{I9M@%MaDU&NoB0U(2K7)mo$*KhC zf}gUzIwMf(oE~2@@^g`0Q=FAYohNGFHqMa!rnQY&OZIg@ZpeP_mP-jZY_;3t&gsl?;-So z9f7IiF0g?!rT;B7+SxNhWm?*+8M5*k4nvLl z4Z?na%{RuqLE6t?Qm}|K;`eP&)d1d8A8cnW&ecYnSW9~Kijy z$#aRDAxs#HXUf3XNfR7uPDiv?(Q{de(&w(mT>vo5mf#aL5qdGw>?lc&gqC75x^n`S z!UFq~fEodU?hKdND_k7N>I=MiJ#VZai}!d%dFagq*9jLb$8+0X#-aG|b2wOQ7O!bcPu3CYY0)tLz!k%+y%O|c2Va|Ib=t>x~rN!}}>*p82z)IOp_Xe8NKA2@Jy_P#cX6Hx6i z0Im{h?x$r5`-9-Wla;Y59$;GEvsQqiAW_aij{e+dyzn^8 zV!*!2fR&1vVy7AB3Em0ghR4U=3 zckUCL(ZQ&xX($p1S9C8GkS8QuHVX@A4oU*94}wO$$6VIO#d_aV_P@2<{>C?A_~1q0 zs5L*%^E!g<^99*HSSJM@Xg1v8|K8O$A_HGEW`mn6y{g;O4tdhKU6?YO^)5~HtAjbUCwKA@ulLDyL+axsh9L_fjtICTArf7Jit$;46PPaLoQP? z_>DX&?)rrpmG`G?51M3Gm6Ou_A>6j;%zmOUqN-@PRKJ$`b8&$zu7i;9S_>FH*THr5 zKT<}L@rs$2`j17x8HIu)M za#*bZ6k$gvVaQ?E7Hdbogy@iVK(2cHTfao3I_l_yu-k%kE1@0Id%t~YzU|ar_OWC8 z&=aS)g>P+};^v*AsjhDU>7HJKoa42Q1ACr!8gT5syYn^Ie^pKXDC=Dfl&(zwmdpIX*V>`!%kUZW?L|9hfM*eZ8Df&SdX zt;<8d-H6ctK9S%4FV)}w;os~q``ZTKq(fK1l_=Dm20NjfRT+4Rw=hgCqp4{By!SkT zry)Xxu38aa*L*|th{e+S{!fn8@dAujfv0pu(s^kW<%4lG7hPu)FK|t;=DbW$vj>K+Siz(Au}nG!xK|3nD*6%fU_$1qQ1%l-jyCk6`x^ zsrke$4yFH}ytBNOsR!pz#y#E~eUR}u(5T2S(>#LBU3|PK*Ca^WYsalUkh+?*sLQ3g5C~R&jd`ymCj4Q(BptSA8Gkx>`Se`Mf|p$w;L>#8>LY zOllqrAbO$kAQIOi=s?3KX=o(%r~-WZHG19~*_jD;5eoE-R!l4%_oe1ELE9IR9dK#O zV8oQMp!#4p=OfbK$#+S@5~Q7U?Ta5uWMAxh&L7-40uaIT4KjpogdJ`Va`%~B=70@a zt<+s^R3=;ZEG=h+aCKa9y%Zhl|7l8%`2yH5C?l+-d4>Sg1^NqhiDgVI>O}n^a)YWJ z7jw@fx2c^Te4V;%FmPLjf5gGY@9wwp$+32QBmQHZL5({aJi#r1`gtRm!v{Z)W1Z`z zkVAgw0u5_vGb47c5_i-M1 z8fFS6ITnh3B$L0ceMvN>#!;U1oo}6vXDZ(KvUtHrrZvIn=b;*6B!;t2n(A#EoAchK?sD7xxo32wE#cgkNm=uV3+&1j->)cCDlpWc#-GqGL^;r#Iussg zLCt9y=jc&eXe~*#cXv2tq&f>FY4}Np+b8;8Y&DYSlCqA;p@t#58o}hxc~&-Y@jVQT z<-)qfG{gx_7y4NtP4tY-lYrH982Cc=4x)z{X}uBp7`IqFxg3q#Z99eR@#G>_6KIB^#=p$QHrDXU-IO!3PAr_+MV z|3cwXgpl;x`5Svdm*N7l(+-@nk71D0ZdagoN7rdk7bv6Hoon5@KC6imdF@7;6mu#Q z-MGsVc4j^)zO(09?)7l&v2PRKyKA?x1I-qLKN9~uD-!1sJu2LXX&I+kkf37<%% z0CN7bllQ~O`0kwx4p>@J!7VkddYx~-tlQs_PTq{xe>rt+aWQt=VX~Y|EE|ijXa+*i zlHf3{{sFqG!@v%k(_2CXrCkTxsnJgm|I2)ejE;U?>AKJ4fL{uih+6j%q0gd^gin0# z&Mg`?3x)Ov3NdaW-?rE!u+wnZC*tmVA>P|?Hq7LtQYp(=Yp4B^p`SZHg?*lufF6n< z--+bVjFm|2@K2;w6k>7xVkcoK5eO*#^hm(qu{_KMk(x+hmr0Md3RQ{H|8XwGBas4e z|5E>BFKVyZ;a+fiN;La<(&1-p3HFaHfVQ7eVQ5u1NeeOk*`7f^3Z@)P526U#|C$*g z5%vR8WxnEfus69qy3X{C^;n&{X6Zt_jkx~76E<~8;VH+)&Lqk1|G&t4^LQxVzi)Vi zY-wc8I?5DTOSWv2%9f;*J*LQ(kR(%v5!tsW6rn<7&Aw*rlu-61W}y^@8KE#qq4;wjGxg;8ns;dO;dxo$RsSZ_OH)CAQYh(L4{iXxy`LG+kXI>WX#N$DSiTDx! z*EFw3jjoUeUVD8n-a`Ra2{IW-VVxlz2Rh|^%o4D7qaLCG7YXBB8#SAm;((95Y^lZP zM6`NwI{|rd987Lw$<)_q{pY32KAk;?`yZzo zq)q>EswWb8XPfz!1HC#WA)gQJMhUA@q7jg)EnMM$m!Gl#3z81%QQ zugRojUi|4=les!UYc5F4-)Er^WZ4eS!Ch-ZGK8uAz4j_{w3x?0ldW-c+XJ*cEF%8; zCR{&Zq*y<4KXF04eY0{wj>TQHJ6_6siC=x?joPtJXI6aS%JRll;X3BvR~kN4Lsrz0 zp^YJ%EFL1Y=h@wk!%u9+A3NCoOQ*&K6!-qcruYJqWARwhRli}+(+VqImoVRKX_QNv zS4(<~ujj~|du(Q8pLx?-Pw$S%es(QkCIX#|qdxq@$af+&FrN*enVQS@*nZ$?Qm9DO z&w0jWaDp!&7%AvNfP#fXN}i8!#0or{Cj21A%5tn#uM`tTwfAY3{{5iZyISiL zIp+agC@dV+w$2A-^!4V7kJcrr2L!!co~UwhydYehB`%<=b#RY*fs_DT^I;=m+KOcv z43;N;#*>w1?7RiTso|*)ag0KXcHl@Y?yWO*m$&}%p+_1R(x4&8QAOjTXjAc>cxeXv ztHClTfi(Vd1|8>1kyCfR=FOS-^^+LtctiSIJ)6M#?^7=oL>OaV*j!RyhfO2IKpVp0 zSC(gwRh)!hc2RrAM14lv(GSslUa4mayb@$>Y%akr?aJ^B{(J=oyE6JaGz})HV#t{+ z5wHUo_fsH0bW+}?eb_M^$qvMreLe$T|9||=>Q;aHRmLUg1*tSitUFIoKZMLL^YdM9 zpEZqhZmzb+UUx0ooVQ;5?7n1fuHp*bT}a{E(b|X4QDLb6JYe#-38d=X;Pk3(r9krXt4D&1F|%f@k5D?znyH!NF>eJy?`z_C z?WaFRD>aGyct+)|n|(L*6QuDrNZ zGj?cR4l966YTLZ4dQqDD`U4KTA2CV52Y<#*TlIt~6worjO9d2wjHEdQipbkWgX}ko zwhm%92@r!Sm2U6btVL=+*Pah) z|3=3S(>$Z@5u_m}>n~fSlFnC5Cw$$j@YT(U=A7RIUz;Be(pOinvW9aADpAh@7_Pty zi^shXdzp5me4XN4j4|0DTy2gIZ9pr}538y*zmI_|ReJI%~HI~lz*s+e^#6)sB%7WlIM{XjokS8LZ*a! z5pZVtdi=HnPI-iJ-w9~V7S>G2HZ%yGA6`7COgtne|NWBv6E3MdM;NOJ3o>3#H|C2l zMQGHLx)jYaz#f~_q__svSG}*H9Vu%NyW4Q?96ZDArxIvRM~sAZp=wenh4kZ2c{3=N zda7CsLmvC|SKA4R0tx5&DCJ=Z{QxUneJ9VESX#4g{ef_}gf0-)xG(nmSMZlP$3e{x zgRKe5*4^Kcabw>d?C)~R3Yf}Ht^w|t6J%!-AO*RtQTO!HhY;tp7UPwwuf3=G0(Bj| zNJoN(#K$ZZ_T4?5P7QN=vKNp>rQwL_sLh8Gc$O3(B;DW-`wC!fICnxMt}1W!2FYq4 z<3MijZ_>AnNV}M87E^1p-D--*LQQ-gvmw)LY>qrrR4geeIjmR-nzZOvJoP+yN*~c( zR53+UL${((xcOpbxs!eKwdT5)mcDOWOvQ)$3ky@@E7;UeE%(VH-fI)64(Msrg%4RX z3)}IK0e??Rt#vO;p>|(l5yt_1v7E>s2a!zFMizS^UwU`O|>#YNl3*c&3hP zOE$;mw}7!PTq|}I(4X8}7B+}#;G!VN@VK4a*EGov-KF>++F2heqw4sSLL_H6UOHV%LsrCD8iCaR>~9|2`xEhZIf7|PCBc)@ca4`Tvi^0G^u;=3Va%r$2^)={VHz%cuK~_ zsLYrh@tS^3IO6HSEV#miKUhm*>5g$?gPppA0|dVq5)z!A zr69hh`X~Jw$-?(q>ZI4O(P_jPFBG6egO0clHMvXS>W`Kdn;{vpmJH4gL%{KKd zh2N&;jYP(C7A-lbpIXwqrbOJYV`L+YWVT#^^Lzol0Q=Ynuz?D+$@)lq&x>t4jmNKSS~?T@Tlxjq~Ee#pQ}O(+G)J zo41``f8B*jc1C`v9GVjGeq|Bjb^6&mrKUgEg%SlV!=f$&0#bk&0B)-TjdMeUqw76a zGanSOzRc8BK4!?hx^`p>AAd1_e@+1DZ-SzK{Dbs-XX2{j@~8IAevYtn|BcZJk!Q#( zmCZY_KA|~plbW020jgK~Etw-d^1SjK>4}^y*RxOqhaW@$t}=Fm}!# zMz+*u#7O)Zuit(%2MX=laTHOrBPNPJ0iL*wTW-v55y24P>vik}J)6B_VKYLr%gyPn zGyIkDl9p+DnYN>`)K%+99Cp4yEN-0zNz^a6!tiSz{Wp|>p&T*giQv|t3m^`e3o!Z3d7PX zb0+yk5mb6%B-7UQGu{uQO8Id^jxzH($|nA1M9*^;0O@oTWDWkv16O%TO{)7a{#o?T z&fY5NvCE%6Wh&PDEmyF0!9JC-4S|5R5(e(Gx}u#$V2HABM}-WOX7@$*2G`RxMrxyV zQn%mivEnU(!S297k7ot93bql4KKR6S0+0`cr*si&Zv$46o-t7iHST2@t*@0TK_4%P z+TV>5cf>s>o_DAC-bVr?h*==;Q#qxT1}M^XC?tiETKN?X z9r9-g#cUfhpKKDuF*V!1v@w+EmZi1vNRXBAH=m4$JwNjH%Vn-|Z&w*hov2Yf-{rA% zJ?h2dfXA#eE8B9ceFYQivS8`5uqLB!EzOpOE2OCJpSMeuxIHoq>@4gTZw`s4x<6Ao zkS?fm@Y`KBrtYpE07g|0ahsysYAT+NYxwHby9+rSv-CqvpG z6}JQx0x>igf1KGF`~gaR9G*10skI8?2xcFSIhDp_#Rj)AVvDHI>1NFA31?ER}`a)v8{n3rmmRnJ$OAL*GX_e8;?t|j! zXS^vvcke-)6bX`VV)yq%efxES}#~ z0*e<-*Ck*zpq!#PXRkj(D&KP`&r11ODfb_xtCknIyE}+DPyVpAwR3lOcZD|q%b1e) zUtbI%k7}6*9Yu5!pd-QUe_P?y-eGI6Ain&Nl3P1v9}SDjEc;Dz&z-w)I48b1{*`B9 zjnJmS12kp4#MvvaO03snRw-_EU6}zdALgK%xux-lcHXzMl`7rC#sQkT`{jGU&bC8O z2e->8j@26YY==V@g)0&UpEH8%gAI??CHpw!yH-lR!$#Y32s}LCT(r7Q4`fI(%eO4g z_mOZtaCT_tw`ttbw#YLRhd!i@OFb!DkraHDE+;>Nk81Mp{_c(Rcp#PG;g;6Dg`E5; z&yW}_oA<_1uMI~E%*;!P$d{q|K!~@Qz-uc$-y| zyh(#HqgJC7QcczSC>2UM>2zv`go2GRd-&_qu>IjSB?_Sed3R4)nR*%g zl5to7YYM+Rkhq#dcY(gP8q36x(b2j+V-$nUTpoP%C&}&WT!a4lCr`hOn2y)*@HmoS z_j5~_4f+cv8?UeiKo9Ctn*j(e7QjLJ9)$R>yI}Wt{vZ9@p=!HoEFDew8aSAA>z)Wh zQrDxl)PU5r)$>7bGF;60=<5d_cX^-Zn=LdXWpeFLp*+DtyvFQ5OGm$$H(;fJQs8->nyKa`T8To+1jKIW36LNvKNm1TV}7o8DA_f z&P5YqA=Ky&pkdUH^#5Qzjr-sdR~{=f+J=c?x*3qedwW1Jz9Sn zvXplsjK6=xNtfkbyF2)i|D>w+aoC9^ab#Yj0stwpz()Z;5%n5=rnDEp6pOmw-44^h ztaLQ{WmnDvq>X8GP)Ls2#hQqj;OOJ2rtbsR+u#+ByX^AvwdK(L(TqRfaqefXggr zU3@{i(sxFh_aB&jP4z}1Tg6)W&E`k!zVW2AZ(2mH(w1lEIyqp>*f;pNJP3=YfC`D} zAsV<&y1??V{z#er*5YSDkpIDn&^mdV%RJ}K+ zcJWGQm;WB~$4OUV>K0P&FP_&R3h~6KJgOsV#_bY_1@eGGUr}6}3Z|>CO`Nt}1&;}` zJlUiBa;LB{`o1_Iw)%1GNx02eGBMiv(`i&miPGOz;N#xli50k7`3}Gxc7)Km4r83B zA-aZnAT{&qta;Qvg5}%cVvAsZsSWcWW&dVt9^S|(V;8YNJ?$ma7_dR=QFVRMbt}^6 z;WKLF%~9_^2fp=56XN{*$`@~5Z#NfC{M!FGQ8-AkOE!WBK%dGlyV%kB7`Mmq-sq`3 z5?8l#n51NAU|%lZSH{`m9(PGoU+JE?XD2^ZDVxk~+4V+ZD zMhgnIBH>>C?fncI9FQ|lLhD8D3Zhjq@A_Yo^tpR7vcGb<4dgbGU>(_U9d$TKlq(!k z*dIsBCR6@IZz>_!W?ZDztVY%|FK5~}wg7*AeYNp`sZ?Oi;$qEhm|K9;P0w9*zOBejx#(Pv1^q(3kTrn2&V`4{CNK{Z=k4kPFZO)$%3T zgJ7x|hz)^Uwj0oggdl|EAGPX{R!VCvO|K+evuIL&CBKyFdWrA7cxQxq0J;=Sz0=1K z03v4us&yXdyz!@W8TeQ}?aA9~?IRhgcjbo9{yN_v>6g~{oc?$-R&TO!#KDt^5n`y} zKV1k&HIEWN#gj!-$O>VGj0+P5U)M%{+WR6&Ip^tRsu#b5)AX1y`9>IvNEQoxVe^;?Qb0Upkov@nfJyQ6gmh|VW(1!)FS$gk* z!|U8P9bhOOJox~8R*4nv0!(=48#UHZkh5P0oClt^ump0~ zp`22+Sbb9x;=mln*1+LQ!r=Lt`I!;>f@W;0@&biZ;=&%1NcO#`xptKBI-dBYa#mt~ z+lw@!&mOSPZ$K))yGkwV6?IA}HFbzTEFL^$BZ}&u*)t3sU8kVPDDW?$>$TEa>vTb1 z_6s8#Emfi0g}G)o(;gf-$kXuAhA-kG8Ph&YQ2|98^hbY&C>M**mwt%=E@TmQ@@F%m z7a@U>>|$}z?!O}$$eU5yN&^a~jlFM#Xx94ul4NZ>*yEpOg3NH;J9gFhH)0ydYS+>2 zu}aKBhDf##D^3j2rBb`o}X6fyD!Xh0DPYo4x;t9WcXwHs{-Od93P6gpr~d@&MWq z*8zsuui$}RTwgaj9=$(I0W*7*w*Bg+0W9(&^zzU-KsI&n?fr0Sh_H>`7_^NQS0n%dj3kO4h}ae}tg!#zpZMD zCrjESot{D7V|)2haZN`a#suG=3m|~o5MluGh^P~bX;a1`+r9)Tz5ROQOOS!w)fOj2 zbs1baby%wW390pmbt8u_*UOh-KDZ79idavm+nWatp$c^3%}U-ueeZ=zIJDH$jDEc3 zE7zl2-uup7rI5L*HDVSg$6CX5ttT7HMqk>$=xM^LRbc;X{C;$vGVtR6&FQCCV7e<{xIBhgt@TGfIEf2Nvq*CAq& z=qE2t<$SpHuTtc{+fV4v>kKK7L~zD1-?RZE)^SxIO>Jl=2w^R#>m6WT-dAJAhY!a` z7<_A9Z15w;J5=M(T@(7-C;D{ZJF%%17GE&^8s=orLyszd`Z=t9OWygG z_zfL3hH>Bn?~^8^&H;|HuLI(8`;rUo1-WHYPa{T5|E-7jlorDf3E6g}4rLShuUxF7 z1wYcX8(S??oBh}@ZhQ5BS+xIOk57AvW?EhF{&doo%L7;ry&8Y?SbO4AFeBI}UFE=M1_IW^D}yid9)^`!)>9Q2{P z(2#aMi~kce29oY$!sLj|TNyL7!n}{yWtiK%ID@`=g&tP3tl7{qRH4 zr)A?=v88`O^EP4$eB2x7oeeZE{`B~RfetbuQJ<5P2U|Ib3A7m1S9v#FIQv}oE=P4N zLH+Y$+EDivjMK&jCV}4zoEc&!r3WRvX@KfY!>Dz(2{pCYj0ZXSx~sjdZdj&BDYB*Z zAH4iT-NZ~>lX>2;W82AoVQ5|D%+e&g2;&Ar0^EOt1+217lG4~tf z&(lO3g5G&Qj8B%3jFq{df4d+u;-IJErw)67CGYqKKrMtsTopbtj2}8n@=vI2<%6fB z0TJ8rI!hV5y77ZEl9#1U43+H*IML`zI_)u)e}C>%JEAt7grZ(SPXQ)51;nZx4MAkK z)gfa1TD^<X~?Kgog0d(AGq`Gj#Gf7_*aYk?_IF4n+*&J z1l^teADAo(mWPAP$>1$WSB@eetx^{E0UE%Uj2AIfxg{Z;Tvh6&jK zVYQJ;Px)A;^@ZcY(RJsp!k%U#6IF3MDGX7!?Had#VDdn@`ah!F3Ix^b4@2b2LJ`H3 zZU>MxmNf0Yhke~@`zs$W(R>RDv8wKEW1B0DYRbO)jTO=f+{omg8A*{an%vRBk-y=H zwlTX6G4L&qffuz9HZG&WY;IGi$fjlY$?%QbLmeP^Wrd}Zl3W%nMk&4&KV*cyvE;^T zv3O3LhwFo0SShFut$~kz4;y#kVb+I9n1|Bx`&`sVgRBG`ereB`#?^)7T)EiN(3ErN ziH5cLpNJ;K<+DmKBcP43#oXnx{|IUqj$r1l7hH-{3tTD;Dkb?vmN|JfZ*_0)RAj4s z@{0&b@KAly|L{%I-{8PRP;tVd{!27cx}(6p0Ys*O4+7LiXTi@aKhX!U#S+U5eU98{}G zwhR=MN>+=>6`|%Pcs>w>Zf*POE?vr6K7>pORQ8jwY~)SoESA<5b!B5pT}JoF#DEKw z16gHMxdWk#Zh}Q;`G}8@1edxe?F3rn_Og*vr;*mC3dGWV5!^_-Ehbn%V1ic4cPD1v zPj(_==MN|MO5^%m;3Hde#>)9#lngR?;?&Ipr6)z4Ki>hiC-Ah)hijF6=%}xZJCNLh z8mHxhqj~T|4ZS%*i-JV6iGe^r>We*T>>}`&f+ypbi@`L46`+amT&D!Qf$&#rzEZH9 z<=$EicYEB65due=K1A6+&Kz+6mYvDvz33^jEzb}*$>J}82y{U8VAP7DJtco%iUt;- z{eDBz+o@lf1H~4l-<+qvIi$Nb9zX^R4Viyb7GO*24%-B^GZ98Rt^*7#gF^Mp*>>=9 zuMQ)y22}qZD-kS;n%7ldKYDLPjc@tnuZTr+AI`49J8sy4W8P*Q)RK}tDbE{v(fe`4 zCVX9Am%og+1Orve)y;HtmRZv)tRCN@XVdvUJVvNukayEe=6F;44VppM>4kA1ic{Y^ zh$csze2g^?m2TB;-cIl={)XhWMW%TPnsDA2%r{sg`Vmlr z4^w5Fr@ooej?)(>WtUr7(r+L23Ca=st{M)6tcOlUn4Xfd+mHvkqTWDx*zR&;^V97l zs7ESOU8C$h^mgswSQRqL_)bYHwFJf<4g;Z8MEtJE$yg<%B-S@PBeYGKXcJ+WKyj7W zGt{;^(ro!n?b!7rfVGgG;1OrH`dcsK+>Jx~VifOi!xryr+B!3R@4l!S0<;ZG~iwuf(^`T$kflO1HYF*&FUyjfGunp1Vxy7!0x#Rtc%Xj+w zm&;I&c;Xt6rpEW?P4j_(fR`o1P@}zlM^w@7ei+)@QoJmI1QN-af<4;LQbS)KRPSU% zzIoKtoCbrj|KW{i9mSD0{(;ryF{NU9LHb*XgT9At0{KkdzqHqQ;yBY{J2lkGG?V-z z+fPHM&a>)US>Lq;bFm~*O7!B{Lr--!KEDzYzxEo&4Euk{>Z^ghYhZ}5FZfg3X_4gM zE~_|}&Ul-w*Nkm^8}fbIq#yFb*yX7X4Jk2r~ zM>R)7Dw7oB0#MFm!Mz_2_o_)^7K~^y@tcHwx3Wy*HJy^IdqbR78hsZ7q*696Jo-}* zZ)6L?H|0b&rZ1p&{@;!D`H%atL2wYZHe&f{!LcZkIpcKI7frt5z0gC#<&hu*TVVoc z)L?f8WsgPVvw6ADveVWkC)3ziIBY#@CCrA&1V!y!9Mz| z2hLcMd2423izCpW@N?Tu!DmBPb#H`Um*kMT9?#rYi3CNi+gAHo?Wk(2q&79EV)7FH z3q!;sQ*dVLl}|#`ku04ae$hPl+8%NceM*L^1!pdH?@kx-0dekiC1hBLNd z=;Jyp+BhNnHkR4iri2OZ%|(o1^~w-JXhLD#$n^pLfB@wC&xz`llD*5HKAD|b>8Sh# z1}-eX*X9G+KN<|B!a00$IRVU5td}M6UiW*eZLn{MTSyVlp+~cy-EGa&yq}23?W^EE zWh=5{cN)Q!#7_39RIX*W z-|P0@u%{mDikA41C@>Jbr%ZKKM40a6rX}4qtQ#R(n+IvwkqF^y`dpQ!3s#)9 zGKVKMP6}x6xW|5IZF+!A(2GQ#>|K}9@9(92DwI-NE5rhDAg3jGPcdFNDp-LjLN`KW zYf$m`H6L-g_oMgdfen<*8b5)2Jk>P)NH#~f4hK0L6}zs2ZziPUjL=kfM7#e%FBx{| z&qC=sd|5P1kao|}BlFF2;HzZmi{!ES?5G5!=z+hq2%_(|^GkQ%OQ|tTc@`>nw1J8q zaG4eXprWA!cn!Ec!IKOC&ZYMtv+_N%^!O$fooI;oxG$?PQEcz>kcCf*c7NJ2Gh1cs z3-)7|BR+J1MR>;WhK`Vjr$@>4ABZ?9FWs2vRtHjcef3!FZ2$J4uOd@j++N_*^7-cK z>a;SR9YCM(9TF#ldfjdl#6R*R2Hmw2UE@z=J|uKYeETB5R@=fY^&nHQsk0>FQ@*_N z=aN@7Fq|n%9oMlIJY(9U0)%Ms9JL?ZIz|8Zil*aAR_@NsyPVMx{3v*?Q@szVNwB_z2B-th5QyVAb|7&gLD+k}0LttPgMFzDb^N$!E-|!5u!=Yaq;AsVo7KOYb4dxF zs`U6DB#KIZK~*R61#2&+g=ESQ6(YZ_BCEldC0w2^Bs2zv?%lZhS)#nSK3WcWk%#Mc z?5Ba8Po}arJ$G1@AcTdK>=@E_fE)+DUaN(g(t+di#d-&jU>Q4etatAW;>${L$ds(4FG^zfap? zX{R|R*F70BB6Lm68hu-X`MmWqph`Ab?*f%Jidt=@@ce94mcjmz#(Rwv2Bjc9X|X=z zgzCN7*=N>vGw0QA!=?Ao)uV@df zO?_zxDkD)?bDsq@zF*BfsrPHfeO+6_n<;KmvC?<-qz(W}v?tdI7lc=2RRF$j+@oAo z=m=QtXUYDe5bhFn2b%23%su+mc1yoWZF9O@IY0WR{Jj0*@&0~${$F*I3O@&TB3**% zj$tFs@YNu?7G{(j6ZfVy&xm^Q#nGY%B5(Y2Z7$rnbnl}pm;SyV;Rb^7to#0WB7~CQ zn{FGRa&=PtKeXnn#mVo#f>I?O>J3O-NSyqJrk({uH5;=m-cedY{!eSzT)dl!x@r99DLdE z2u-W9Y&OA(Pvdj)f$p?Z$SAWBjo@Hc5fLCj&r`Wp4`fq<@x<-QM{TMtP#-mttf9O2 zwe}-Q?WtOxt=Q4fx(~F*H1~)z`!8qdSFgdeCT7fS*{RtB{%Z~2(S&i(HuG0o)uY|U zb-c8oPO=14a4gY1AUxqYK>d@3sx{7JAWf|ai%Y>TwyowL*#E}GcmO#D{7b^WZ5r4!otZUc)0)H_h%zhPs}c_6@`~@SN-rmscH-5wDLByBlHT z&IM?3dm%AkTBiH9-++!KHh{!5?Q%K#wJvUAt&^tp@nEpJ2{Ey>7r^v%(<+k73LHE>s87$$$Fgiw-BZ68L3|I^D&F2bQ{XMJ z<*I%1uI2k(~^&&NQtP= zANh;Zw3ATxMs#ein>)7?!+qw!ZJQ9(fGwbEj9&0~L?v|L;S03k1hv}52~8o~!wIeX z07X%7NZKZ$e^7GqI*jWY5Fs#rgU>02Lx%8Pj~zrEmW|pu^EYAXV5|A5Cr874Uv(ue z=nRWj&ZDOFwx52%-&UJ$=)egvPD(H8!jozn>nwCCMsL#Ol5P4fL~t)uoIEWPWh|0M zCI~q~N}bKQHQ1X@Do4SbOhiJ{$xx z(?YX;@Evr`Fz7#DKWQRv4M;-evNo5OSVGtfBP>lUnEp$w)ylFI>&ddIs41%b`uDEp zhtJ0}j58r8w+d4?XU>1IqlxT}HFtF4+4Y^NXcq4Z?G0KOgS+(gI*2({v1i{5>V~ac zeV_9&o-EH%`tHy(;&%se`@Pm&+m^;K94DUBkQ>ti@~X;LkYxML-h8sZC?<9!H4-!0>!O=+fAqFT zqgMT8r`hM-8y1Su*Sm%i6{AimTfrn?EEMD4p|DU`KjHnKo^mG^zaSJzl*F;EY+z=4 zEwy87E%%)>B)qfFrt1}?=>#^`bnQz{UHJUOm$%HB{ejiL<0DxaA<#R?SOBq#9R&z= zO|p2!UV|sG0<9D{*8a<8=^&hb##}i_+K{+LmtNHoI*g+ArSaD9HCklPg0bWr#jZq@ zaT6nS?I?B(abqf?R}59(*IgM`N@5ye*>4aN^7dJjH*?hKzMS0g{p9!>DUKAF?Ozo@ zC?EVo`RztvDu7_VTa6)NO7u+l7Yi?>;NQy+?{z_p>7z=VF{Rey*Q0-(*iNv>e&-Q^ znGysMHRw;8ULF%r1W&5|uOK{<*=8sNP#s|)2sbWMi(A#?QK4Kp3V@@}e`I;O?i#0) zqOP#MYpLjONB*B#4-SQwL=pc;mP?Y`K6nDVnB~_*oOP;CFdx$lz2&4g@Y4=vfjlO} z{YUCaOB}DsgFzVXv<(A{CJ}cx76}e1Ud`1R1BwKWkZG?Y=VJ-2vMWL5oAqDH0(&cX z_LxmoTz)_0e%trD)jW!N0Zi|v<9$KbsuRV_2%_c=cWk$vYPOWO7^}^(w)m0Eva^YD zPg=Net~*_sNAdhiH%+?3X+(QiBx&w!kK^}@%)+VpTaFopx{foBj_spXK_}l|P1d;N zkn=oTc#pX(FN=UDzCiJTu<#LxJud?H#!s`&KAJ-I=YzydN46})rJYyTZq&pfli~#n zN{?}IBbx)<@7NH%t}k7T zJ(m1{VN=xJyK#_g=HBR_Lg2mm`GoeT(}yypxi#4SM2RqdL!&Hs7)@A4ZCnn3SKEE} zFBn-g*u!QjDn@3vx^+gxHq=un()TP>!w+K3DURQtOS?F0NYS1#^NE-T~`p?SaE6_p*OQl zC|O}^cJ7LG2=_u$n#K#a$A?b^9q4~t)hEyXx-y3)1vdA(L_smPyxU)}P!8UIw{ zJ}tlEYPycQoa~c;4$a94bOhGflg8eWW%8j&BdKWR{w2c93o)OAg~qAsdeUV&6vt<5 zzw;O(lO(Y?LxbHT76xc#J9aF4Kal+epz|A}mZOwUasEe-rAqk8is}$OpSJ(N$Aw-o zZT+&Pm!LH3l%O|o%8`edD<}2dte{lx7_4*;x9kRY<=X6aA@j{W)U@1o5G}KNSjhCj z(MV#FqOaqYirep_X*!cfE(Oa!f+mSWz#MXQK>fGu5N;4VDWm4MWdBJ_TEL+TIW6Ij zlcsv_1Z3@!1)oF{{S#V^z#1ba^+P>L%rpL+kbmc7vqyf}?O7k+pgUilU;kz{C&&Gb za_!09i)G_lBg&e|QGy(|q{ym<(J`QWI-w39>c ztn`eHt?T!GwlkX?PXXUvD*E3+sfmjaGbT)B7=)z(w?K={uHBK2J>3?Ii6?Z;o3XxT z^kd?sEif^9tzBM8vI#QYq%&fNtdhaG{9g(rVc!5|NA%AYC*1sdTZ69mQ@*JwJ&rfE;MN0I^`1-ab5~hStZ_8;p=Dnus3*gFTqLRR zjvQ3Ci`W`9*%-(JLcC-fD#nZB6Ni>*12UdClHZU_xLw8unuUBN)%<0L7Cfa1?uO z@l-S5tie2SFzFu{tTMUvMyYpcIU!+XqN=94CiQ!~N=3=%jgwtRO<-&f7vmupU`-=w z@0wGF!8r=pPy2C@VR7nu(~@|McwadTcW%rs8w{b=vLfkTVV`$31xXnH@=Y~K?{~#B zU6@Dmj|FW~+xtINzwak57_5x!!oL1L_|uxQ-N$S-JOGve6(;F~X<6V(s$J*&Y1iQ6 z=A%p<4AlJgnP*y90QuBBoae};D0X@FtiLg=!;hFz02t7G7F5}iKY&^It86HS6o_^d zG@N1jYc@jZ^>tOOSbk=UaUNoXM|ApmOo4%@4da0P*fP+O`+` zJia;iQ0;M;knhpo-6Kl3B+@CP1oWUpxG0;(3-`wYPS@MNb-#S+_DkYlG`UI}pisjC z6cf73h;1Ej#E>J?q(XB02{XG@jPArZIBAGroc>bAFedp6v|n$j&3TBsf6njS;ar+;hP@*zAe-M;(fNPm2O$+YE|iOuska7X9I;vC zt+44#yIi$kC=K4z3)BsQFeakUvL2$n?E}O)ranS*P@qFigXw+VeNQdDs?LR{W~QbK z->iZOokj#-z9vQyqhG=TP4qUtTX_cz<8+xE{OICBs<_3OLId73?p_{| z9X{X+4nyAby+6fdbjI*Er5y*l9Y2R%(4`F0+E^2HQPG!e{PUCDAI0jwSye6&N)2b* zIcbc|quYU7-bJ~ir(zbQ3E&vS;l=I^!=Wy#2&IHUEq?jSV~aHoCW{V^BlB02&otsx zrVJ-dX6wdYrcD24?cc+Eg1Tju=!gi%3eoZ{##-&j-mHPQ4s%}3xmlS{eUG?0RFuC- z_3`$r$s^<;>y~HcoZSJROS<%~y`7kuHJ_Pc0Gi#1N4r!d5k|iN09Y;M z5L8I+0-h;0q1dxq7Z-!8pXr>SzIh=W8a;0j^z=Tq+vL;75B5rXFR?+OHNlYsEZkex zAZptNs|GN(A&&IG|H`Zr2DW_%(og``q2#nZ^~Hmkr*DCXGgNVywF-Pay#n0uvdGu7 z`t<`ijsSyCvL2Tn_ZK7Y98Vnh^RKB1?scjVi`T#S=fjwu6dsFrx8@PH@uQbV*KfXW zJ)zC0ejq2?dVNErnr$&igN)gxD7lAaSGPe{9@z(6S&WG$c_2 zqa~ZT8rPIIo!VJkUs)0ulqJYqVm4zdJZdI+(+DvhcZikRgg?3Tn1ptPU0!$@-cV6!r!y z0^Csy&dq;Dh+H2@%;RO;9$D8nC;QaBA62Q?G_bpv{DZ_=bO{|SyHk3qv)EJeMY@SY zxM9OCKW2wk8#oN-S&~zb8+kI??pn>rn(5 zc@5X`3cnGfqQ>f_p#OoLiLXPE1<_LttjFLL;z%~ZfYsh^4z)LIA47-v81Jcv;>UOl z8&lP~2<*SsmD4QMon{?6B*Zd)kUeGZ?ORvM?S2{$+kB`*bOU)Biq*Ls@DGe{1i?TC zt+UQlzQMVop<`kl2uPtXCTQ>qu`gwlAn63%`l07+twZs#?M4^)()3 z1`vajs!32;ASNZi-P*z`pBqG@~P(C2+Uu0^q-Z!T9t%s zZiv@<5`O1VgO(1*7p}?LU1MseM++n`MThNSoSa~|FR1uiQJ10^r@a2mWE6(TXY!XR zsN3fjZ1bvrHiHSCu8aet&;QwD{mY_qq35_(1L(n6BzXLAhGruzxZ@v~r1!kWm)mPI zy+7;^WzG1QKVz@9mpA*7_{2!^%U&)yxhzH$v%!!Tyi)1fyqST%dJv(tUYmg@zaB4e z4!KArPD);^{?PtA->S;#qhA@q(AA{xX7*T=yE_a4(iUUXUklvocfi=dZVn8?vr5W+ zJOY$8lJPwM7jthO4`uuR4Uc5Wl5EL3$(Fqm3YnxVsc1o#Op9G;Ayb%B2w6k8A|^?c zWs-f#G}aJ`>}3{7S!Y_t$;_PBbGYvNcR%<2y?%c_&+~fzVEkjw^Ei&r@!3D`H-?&H z%l1D189$(;n$|(uuJd~#DMf3{0~vKnarVn_JN8LA)wtnYPlwmD^PEv$|7!h3K0t{z z2M&}7^%}OyLlY-z0bz(o3AQTh8>V({@it?XH4n?bj4%a^3~Wdo=i@lpFPW(@Iz;@O zD=W~ikTjrZqoNw3>L9=-@;)s)Z`O3r1dr8TP`Hs> zSXqG$$B?rX41)zCG>>@!aQRa}f6K~vUl+1x#PCszFkM)-HlQ(Rda}L4*@^Yapz3nE zRYk1DV?G6*yB=gVA4C=b$woBjyY67G$O77fz!6fSzAnBaH}yrSU1`6U_y7~6dUiJ) zxZI$FlI^lUAwFPVzhc);)^`uGg(jdMuLoMu#QAVUIADWAyCRZ(;zt;zR*w`+Bi_%M zbbWW$3K=#Ib0XJut4;o3^u&+_hnBn@54`g*_v1b`7X?SYL%;P9Y>pPiO)s2bisaTd z{iw@jj?sb3C7h zF;`1sPQ~56oMEZ@TCvK4uVweo-YH9?cj<}U@{K&=kMO`@Nq7QXg4XpOfV#K}5&9{O zy5r1|u}2)Z?4!_l7U-USl8ERmup40?rZKI4X@q^?Je<^iCoY~TOe3b%eEa$Q+^-5l z`LHhv>9+Dwy}jfa+%req7g<@yhj$qR#X!|RLORzAimu;?kfE1~cBE+Jh}~ZvI*X@d>YBhMFp^057h`j)JGeJ^uq*Ni3jE$zn;&ovK24LY;a>4L7B_eLQ?Mtg`FsxI zVyznc@W^egh92HS+VdtT?KN=CVy2<5^BWy<<1fGhila$I^J~9YAbg7UhydNg(T`wD z+mDDYjAeU;XvbW?6drJc6*k(q!_%dx{dOQ(^RWaEpd6JWRf@reGKfPOV-RV(Gl%5s z1?VHnYTP8~yFC+3TR1};M|^aI2rN&-Ob}`2@l=TSd$zeEgA^Z_J=E4UdZ?-+rt#0^ z8+J;zss$%ep$|9sQ}{v07$+x99va89|3)2GC0cE~7eM3$tq?vCTeDRjT(gQ==rpCq znIn&3tKf%8vb7XNT+G|t;_OEHL1ZK%*Sy-C;QRA|u}QnEV0lN-Ri13s)e7u{3L^TE zn+07arMlX8*z?0^hrEvU?caurM-BaIcU+x4(9xq1Tbc#kV`lZJ2;`zTpoc4_ADY!E zPyz$5*8y4gts-njE%z1ZtAnTy&^$oMON`~D$2tjHdS1-q6-Bb|q}=Vg0)@xSF7Qdf}d4fkpVrE4M z)hpa2Ec?1)WV(wI4`XW*gGhG8#&L{`$G_)pO}d(WYy0-=f=_Di^8)~66p!og|LPL@ z|MHWE0}=vc;I{odPx2wLc>}QP=0l_{3+v-x6|orVJ=bo|cH#C`z7dZ6jHq$Dk`Y6; zp*C942Vyxc6}PL{H9k$5wRL=YM1F?qS*=i8A;1-Etw=y0M!pAL_#^#uY-*SKX%Vj@dCeX8g z(pq|AwNTnZ^}@|MhkMMZk(&PiA8ddh{O><#fPbWz4b6nMngPYDmjr@5ap!OS&`AR0 z0tq&xOfMl%oz$~S82uS;8>*d@a7o9w<$7&-fXcldp@F*zsMml* z^g9UiGQn8KCp=in9~v!~Yo$tTlS5iD)KVuk~XyosO2{My)%Pc`t2%$R=oj0WSdHq)FIJVi=$MC$L^8A|fE< z&b$BvBY5Rx8j@j%r#gd&GAtli$u^TfQmR>VKh~aU_xUI*%untpeqfYgBd31!Y<#=sppM{MfnP}*wh%|) z)G!;6^_g(TdQoWfV5sFZqSI1XmjJ)ejDSRjqp= z`yiE{g_6vCd=bfo$RJpw#*U#Gj+lvNT0uN@aEB$0u!FPnmu`(gg;!1g@gbV-(+0cj zM5A;y;Q--h3B@-mQ_w4$(@~I0ExQC5+6D~sUD%Q%SlSn0&}{eT{-XR_PKl$?co8Ca z>=`nEVOZ-+BYpT?%*vutY{c=z`PGi5>3j(tC+F3@hm-4ke`H512MeVc&t(SGFW1K= z^R5XHM?hxGAQ(AAc>zseMliphcQuEv!27~j33e0NQ}lT9?ZA=oqd&j+UvGYn(|Ot- zonaDXBha{jjey7~K<0;$0{1}+@)Z-0I0L85RPc5vsWuLIeyFLyw8!~W+1)NUsGc2G zu;X)2ld4cDiubzO>rGLRIhx@lwszcS33wP@&YkG>uqL7kYzoLMZnlwFG9X;}*n@4^ zs1w2aW$4yRD(pbc5y0m{qB}?HasV>2OA~WH3$K|mFFz%`(DSw6o3s&YNcF)M#pJ8E zUO5Sp6K|)rBYgyhS9d2?6!~B-`3X@2FBplA4taCLwBG^e?bcK4lA+RKE!a?Nla-%! zu32K+-oQ$$HXg3b2D-u;1$b9LaC^5Ry3p7+Ah;!YTFB7_HUy-r0BO4gW(?BCp>DK* zq3DfY?E&;sK5kCfkM&9^|* zpcnsSI6r{B1BpM}23YCh$$H=xeqUhL)5kbjSV?HybpZ^e9)iw+xwA0NHf`X8MO*sg z4={aFTl_$GR#$<3eVT3R*pXlIb~Uf9UBYs5RkObN@$Zp#bcB-YoT;SAa73gQOdq1t zrnNq`%5l!X;Tq9~x<~B#b#S?E??TTPJy_X0pu6Ou?&<-{sLez(# zmv0wzr7vyMyob8I6kODiTfMNp)c-o`Rov{;L~-d-FBg#j1FRA zx`EnM7YMuGARn~k@88!3P?mDB}EwQlEFGXe|JW)|v3Lxs`O zwl_ulQ+_9(=#g}rdPn4;J**qO(0OX9d7O8RLR=XJp}_-3BKL;G{kXUUurUBC+ufVzgWmmPG9(0O?30EoS*+*26w7IDoUt1bco9 z2j?;D6-i)x14Nb|0j+#HB09|ky$beVXsA^UU}$tD(J`_Cla@H2Q?D4zteU=nE$a1+ z*?tAMqO3W;z^9ImpdS16kx0~09?m#2js6*dl_f3S83mbz);nbz`TU-l2z8Li?z7&w zECH+}7D#i%96&T8E^*5_*Js4yFp@3&t&+@JW#+X~O1ra{6^{%BJd$&Hit(w#ec5gu zST2;&z;|QcMQ#Fy+D}YGgMiMTuoNPq#Hgmgs$yK9|`*lV*v!ZjL%hGerq5-^~z zR_`MceZ)ap-qI19P*DSia}vj-XPbj#Pc*0%deiD?Q3p5A2r$Z!odj+X>HG5t2~bD> zau!HIDen-8+8w;64b>QPqxai$6>no>v$%l}`3*3cNBXZIf@?H_qW|<>W?UW}lE*PC zmMWD$)`L6i;_4)@q!YFZ*%s#O)Iw_*uH;eajAi{_%|-9#eY8+npy)li?J5_8L?cln0Q zMPbr=`)RHJgIy3g@V}xp(*GT;{SZckfj!_a$1Sjaa~sSom@zb{1GB|19iG_LFj})B z^`^sWXnuE5lzdoy^vRF=uNNx6^}6`^)kT?aY4I3_=U~@Vnv(AifN)L{)-}@`18EQc zlh5p+>-cPT{G+edy5-kv@@}Rq^WRx_5CFzr;3m|Qg>*oyO(Y;%qDmxT?e(wVEa?z= z%X9Gg1mBAh;b>5aJFID>?GZ|7?+;kFKEKJV@~SDa$%XxUA4xc5e{8aq%lcdyXJ(_M z-#hqkYyCv51<*{P*Nz3JVisS3>*GJ9{b#=QDENLgu%~)C$QBj0xa&qrLIqdW;o4PZ zd_Tf6T<+>?yER(0Ox(xg@joR@eIM?B6n1eXp*^8!eK|_#e-f(v|MC+Fg{VKFDJ(sw zoiwFS8wA!w6drbG%*6z9WP<70HA9sJ{mc4ut)FyrZB>)wuT{D2bky))zQKQf?6)I! z5W`VY1g;PpCNl{vG91RVC*ygUGRYhbjhG6Kw!wF&ee*I-VIMr^Wscg393QBvan`J5 zoO^i9#xx9t*VkVPWnS8`2hL{-@B9jN9=i<&j zaC<=RiG@;Zla}qR4<7DrMXeurZltS!L)3fBa*?B;D`bwjHohBHWX5Estx1NW5d-WP zkt2>_1u(-FfrkM2FHIRM-M7Sl;B^>C*4|H7hc4+)D1|pJZ#VLqx$~@d$EL#v@-g@aBuqeJGK2uYtH*7BQw6;l(RAS zT{Y9pcqXyWL=N$RXqD#L(8p6Sc=SERIpD(+e~^%^!`xK-F8mn8ut$S?+SbJQvXV$N z6Q-B`Svua|G->8Uv4@hfQUDNXh`;n z*!Ltvr>(@Ky0{Vp5n z^*`7Q;3oJiUSevn9JncFkuM{TGFl61`mlX199)y9dtAM~b&uti`vvFE3#94h7e=Xy z%l`9`r4O*AI^X0}Sji zm_QFpLeOv?xm9u~LN~OqW|BoP$}{XA=@=c_)hX3mIlq0o&fANgA{p0Em?HoG;)&D* zHYgtQ)0f5f05fJoOq|}&*ji;A&#@0`!V`i^k38uMRQ$qyQ(hj+9s4646F%9U0o!BJoR*vJ0Xh287_TdoU(1inr zcG=C@qf3<%+dZ?7xNEELYwS9FpdXJoQ;8heel{P+6&7G(=%fy;B2LAF5gZ>HsHC{5 zC$#yXj=o-{Q^=!UvR2uef5X~4R0eAB0C)b$CQ=>-HWOe?fN9{rQB&-8Nc1;G9*${8 z#6E(YYK4VYvB#locJuupK<#qiu88MI5?IlnRtoFlsllByh8;BTg?_{8e zik-g#AXuda~+CTI@aep^_5PR1=I)M4q+^W?XmK_nOV0V z%!)j5M4|X1UjRGs8JWh){0;^f^6hBh9;QI2TuD6V_D!SA@^Sy%*!mBj-|9YpbnSI@ zpcn2_oV!xdn}|%N0kXvhOzLXDcgt}BXJHMdd8Fmh=5X8DSB#O_rSkH4`KP(NCsZvz z?aL4}WC7ev;6-g7f)`030*-6lGXiMq`q~gVhG@rR9TAMO*fb2$Y;vc49j_SdTFa10 zzgCtRW!s7JgUG|^dZ=>@oJ%uq=B*LloT;8f(~mdG^?H84#Ls!?e79iJ&>~SI?B}Bs z8D_)A@7I(ar7r5?!(nR{9qyxIrGMm3=P=LGrCW=OzI2BBX^t&F6StF(x0)O{2TD-e zEI?TlLbd{H5`8t|I`Fxv73M~8(i4s$6LjvWFqOtmHVx^w94a1s$;VcI|U9sz`W62?X;dSRi z+K^xVNogXpb$ALBuNq5TaW1AW{Ig9LSK5HLVs z{Ax815wrjS5+7U*t^oNBr6xGVFj)FafQJUG=|v>o3l%+f?9*sc;M3woVHNWeWc8aV zYirlu@Zotu;9f`U(w7PtYsz2kv`+|=nM~=(aR%zM&GJ^OXKLr8R|+62Ohsw03xeI2 z0l>-T`Z#?=kcVxF6X@`e&1v`0gTHtYjpJ(i9aAd0rYdB0o)`M%5w zMDHpFYqG3@BVWJ^9e3tpBm9c6Juk-n>tAD4dIEw~KfH@O(`DrJt~dQbhr-zJHSGIt zj@+XS`v96f2Vu=Ogbqjoq+pz5&D5y9A8{@KBr0wP%{JZDNjKwG^6oRX zvcPzr0I@QSz)=bX^$#0Z{z-&10~6EYQik(6%#0c_)V&bWGdUXG6R7mzut=$8EcVdA zhr=41=QfdUZj7af?c~PcweUl&O0dC9a6Gbg)XeJ{tk1STHS*-+Qo+ylFUymk+USDh zTWPCXg?F3Ux-)K|FsT2=F`6F+XinVcoItiCMAk6Vuf(z48=2c{i{{5BrHseS2GuGW zkHy+>FJzrEHnK%|j{O||ZychH8j@%s_aT5Q0yr+39DN!o;SR{pGx;lCPA`;|yoZK# zE0&M%KjMgN5vn|{O39?Q>ehQ+dWE4r;nK5zM%AL#e9liF9J~6L_CKyM z{@{Nv@9|y91gxzAywL`@0sxAd(q#~-FaqZWsW5m_7}*lcd;(IjcrjQ7eXn(xY1tNT$hEYH z4$Y=rMuwYOea{A24!jz8@-*|8_HGScOcZbm{?lthal63V>?K{r+{egYg!`%XJ8<>q zna1%g+FSEq`uV*W7(45F@}^qb`|ZwZEaQjqeg_0pLy2GfIbfAB2cS#AiKObt`YwG% z9Gl!U783%=94q;8$K9l}JlnZ`pWC7K^55;+_nAtPg61CSe*Avx6+<^#&TJx=k$fX3B#l4@PhfYZN9zT$)vY7 zfB4m_rdrDWQ^sv^E{FNzY&OsAP~~MI~qGE{91QkkfU2V6Q!P&QAwl0O5zHtSKLAi75 zu7p~@oa}4ef{l(R!s^=`eaQoSZ9AP-^=U662+snv(I<2%v>+nKzba`y3NylRz? zo{sz7iLKi+bxbY)O?$>JujwV#WILl%C`CR1N-@V;g+~}A-MQBH_hS?BhvJ<+EYGym z+z#z~ROGTP+E}I0$>X4i;I7tHupb#9g-TM?gQ(?@ctk)Em`%t@Y}w1?S*ks>3*1Q1 zxc@@ZTJhQT<&3r6)9!ca!C6%gEM(`gSN zM*mWN&DBMepL&L&%?BT~zoPh1cu?{K7QEiLQ$HSh6}&iBf(!1|9~Qjb1S5xOHd0{q5Vl?ruLA)~XIqbF%lURhLO^uKcPR_}Q5e z|8IYLSf3;3$36%DVgau!+U4_S95@}B0`*pgb#f3#_qm6M;_9w8dF?IqR9mrrWvj+? z%e}faT603$zo$w2!BKyrjIYvV7O#VMdeiU@K7D>~_08K`L!DIRj~oV1uck2Uph;dbmT^ujCFdhYgV~)> zLy*I@J2E}eOTWwM4!rOQy#j?F>d<%}XC%DSd6HQt>teq-@G{S@mBlyUI!M7Z2AXw` z0VPFXn3i2-WxkV-&m5`GZCTC!$#1Ht8fWd9E-U*G3}aluRuLGtOP11^1~joWyf{EZ zDM>aYisF3E4=soGeGh5QuF0PNc98Q-U&r&Hw4u+>-e}{f!z#dI=Vj#lr*%SNIJ#h& zyWn>OhA-gfEkY!P>i`UL+kQdAUvhcRqTpy<1N34bbp@(m8nBw9EwatV2}5hwr+e(JDFMqknmQvDtUR*(lFB6o5+l2PWtk3Lga4^a;dN z0;!M}pkOfk^EWCgbb%x~@1ez1BgCx`1Go0w^)i#O-v>}wQ}TKpbs7STK)zbcy96DhKAS1pp8>~ z)@)8~a^GmxD<@S=Ju725ZVj=M1RI326yPGZ9*Bp=X3zloM?7cC32vx^#`8=f$JGUb%;sr4ygirQZob1;EmzII~JXdz1iG01^BH zAufPrhodkhZwHRS7+#ciNt>vNi86RQTD}Dn^^KI~3b`_{G6mr>d|>X=bi1c*EJ5AH9BdU~z7)axa+a~VajA8|1l5p76$ z3M_#thtJ8$3F&J+prW97hLMP3#}&0Sx@p^bUp(G+@13Fzuc4ab*zFH1n1vWHCIQH_ z^3VWj_s(i}04dS+D?tntIn{_9J8uwVdNLj=8xQ4jJ)xe?JsLq>Nch*3!t|`HtcTR-Jr+ceK#miyA^lUNr1)0{GHdT2kbNuG1KPmEV10m3 z_>l>mU?TgHew)}m4*^C{ad_d64;~9mghXm%QzC8oitVM;Nz;IY z1SN7X;QcC);q8-ul5>gegu7w6?qTA!vu~b1dO7*oUhvWzl`P`d^PejZ-;}?nDpAfa zmI}1WMAd7E?9T~#U;c7kJ@H)nD(S;61?O*XP|yVm;~WXri|6dca3n}HQa4azI1C$m z9_|l#-a|_g^#!fveih-DgR_W7&+uPN*WQ$V;4>`$DWd%+22pY30z3WP0tWT5=tEe5 zju5&CF|f_IB@xjV+_-kD`OB{uudX>SL!AaY=Y|Wk8F!*EmHM#CAV=nMFuYp;0Cv;E zI!TJKxvoqHns2Fcxy3s8Net`IsojB|&7!`U#|M%xC%>>&Rk zq{9oEc6gp+bwh1!!ScDL)NkJcnLQ$U(lc78M=YErwx{tT!XOL~aE@@`#{Z29N|TCE zfzMLWJnrMLRJYel3jBI>dD(5DK1EHPudXT9TB59UZPh@5@bDtKAA$|*j4~177mTAX zt2bY`{ny=9YZm{reUOB}jC&eK;k3Lpu$Lt;x6vgm;MJ>l^X8qw{iw(^MmHjMf%_!T zT-o<0u8&qvo&v)_x|5Lq)b}QQv0tfeFoEzc+wI2{m%Z;O@|&!-8l-bIaN3=F;5l;B zsro>LDz}C04wR!Aci%{ybLM$v^G?JdIsle&)FYqU?tK8YwU#@J3?6r;%G@RIP-$nO zVy3uqCa6X2K1|@?WJWhjgPXyLWMa<_tr(W+Z~7rIpK;!`sato#o$qje+iXJ59Iu8z z;UAMVF+})_Jg|}E@!>K zdB`V)8*J8>y3uatEcu?jdOW!@CE9pX5sEO&BAo{n-M=PC)Qa39zQ8i7sDorGf6&#c z!)QB*d;+4b*e6k{OP1Q1GN2AciYJMJD=!O&Ra}6*-3ZbX)6-bWw9BBXj3jY_sBA;? z+ZKC7whl~kU@u=Iy`JMJ-DTE$aG$hjltdhdgBh=Xk>#=r6$?rWWUENkBd66&H$OPk zxmXos<#jpruWQ9&%BEw#Kq%59e+;bV6t~z?8z`N7FX%U;_mW2{6EF$iS~98c1C6`H zgtW>GjH-N5ysF3=fWsa8-{h+p!<;MW+eeV)ixNXB*ZAFBA$F^4WO5E z5|tYos<)l^sp>!m`n;9XF`GYy3gkIuiza4qUID%Eb?jF)~|2dGO8!cXiJJ71Hxf(z_J z+S=M;f>U3`qs@Juh*kb43_lITvV_!JXpzi5h+Czfs_?m8>W4=$fK!xExfBS1_P%M` zh2;+&>d<4Iea?3)3Bl2BWi5bc*gO1 zW_hX%sv(7Un~mdu06+nfZZ*n7wp?J_?$S0eZWogPO=Pyn`vxK%7?I~Wb+g&|8E?}oSI_z{VPcBQPDkZ)o|FxuJ z^Q8(8!tWTnD?UK04#R3$=U;xm^YOBP{cl;5F^~r#-?-vSq7n%;PP1NLW>PHTtoDgK zmP4^;0AZKX8|LURy&xV;O2v`3)QWB9R#;Vw#a}fPKIAY}AY1XZVEue-Qa<^DU|{o& zyn5mf2ymDJF&xh_=WOdp z7F9L-=lKV{=x^>zYE>ZlBvs430FevDYaoWTuKtbJ%gmrkE9=v(=Dxgtga9$?rVVn| z9^i%@h(Wf>0Q_GGH##x8RUyKVfsLyjl)=T=VkP|La_$!uM4C)@eru>t6)ddJm6f}d zE%1-C^F$}^$Yl-?zkj{58#t6Pl}li^_Q5yUZUGT{t9h!(Dvl%jFsH$kT`dZ_PGOwF zOdan=gwSkBFuu@J@;%H$KE7-8%t`f}>-z>q&$eIJt$C;FusK03?qsT?o%)dD7Og`~ zt>WOTy;Z=e#$z|}YHPC9%dc4-FzH`I|MjhP`JXjytm~g2Py!)ImLpp@JDJYOwLA1G zYrrha_tiUqg_(R}_*kIu`}w;G@`;7Ksv_wb+Run;+}8ABZY*L5bcu1)pSLJkRZu=1 zJd7R;O98iqGHJ5Xx&EwG5ooSc?T0g#oj@D2qUqV@mgvt_KZpE^TJNhq(Bn1fihBIu z7gp--#eXb{L#0vWE9E-QKneR0Yeqeo+K$%=vFdh~vw;fj-UYK^Yr{vSrT! zr{ovTj(q120#_pM>7IZUa5#p&Mi8u5@|OPcy(Ak~#k-ugC~D)0sETz(VThBUB#1c# zQE31fQN0mZj&N9!+Ly);J0E9iq_*zDx?2?YmEV`Q9WJc-sYLshs;QFg?3FJregUO3 z(En5LD<|o{IlagY&+c7UHyKkMuoPGpN#E(Fu<6S1bxztWb}$eH`2ff(60D^IuB_AG z_zeo(s)W|iqE8Q$an5UZVETE3NtWy%lzCyR#i){fBs|Bee|Lh2gq)W z&J0)~H4AT~Q(lPdp4WMn)`EHcJ3Sqa#t3n7pteV#z?y>`#aU(m>pHigRXM^Kup+P0 z0H+;h>z^c|$Y3bF&#td;>B#!P?VXMJ`2H{7nTPtQ1T!d~i^-LChq517Ved0o9E`tV6-bQ!9ckqzdH|Q(h5R~~ zb@__Ct-;iE=Qh3G_u^+~H|x!*Twef#)j%Y|R=UK}D0Rg4jbSz^L@B};ps}!BY8mv; zIHv*D0MfO*jHA~7m6z00gv++(8TPLmI&b8B1=)%JkY0m9n_a6cFK^<)JCntcbhSIi z9Eop6If&2|w&^{mE8q}~+kH?zcVD_mf}W#Q(SHz(v0zvY)=Z`_j$_8Ljvz+@-@-`Z zq8A+EEj}#iDO5ZT8}8QbHXImr-t*-CM7p4l^0|FM`}Aq`9IG-m5R4Bo3|??~?tnD2 z&cg3-L`*&988{1@A6|}s>31mV=7C%Hnj%7Y@BFj-FnY|GBi{~GTg@1WT7}-y5>c?9 zQIJU^DMlDJS6{FSl(NY^RTLb_%DnoaTPMR#A&KSUbj;`CwbY7V57U0Tl2AbOyKKg- z*$yBw>~2exSCGKey#|Ugk<0l%71)0%vi}!9#Q=}yQ;2a6NCSakN8<6jv(g!Ohu&Y? zmsb5_(mNm3^eOoI>KEnDV%d-6A3xwf{PltM3F>=wIJFUCH{{utt zwF()8Sp1`;-;u?5$qxdnvd@tz0xx2Ow1Yu4qM0Kt#gbZC^t8{sBJxFS#yi*7Yd788 z+uqI&%t#(SS6#hXs1qPzQWz5yh81>Q*<2f#@SsA_vG|F=i-E#ViIZq~VTJzICO)1-a$8He=%Pu@QW>cA#ry_YU z@ytXHKnwWqU=R9Mru30hU_`E-n`%NrvilFCN3e_FA`2WudZF5t?vs+V=$2*BbSon|| zat8RAY&F*~B>zt$>W?q!>I$r~I}4(Bz@;}1ngkWz1?|Ek>^}_2?rCATkm1(0zS^F* zBy*afUiiFiU25{k%W>|5-RFmZ`Srhh>o$&0C~K$mPKe)-n*x#R2tQ+?C=yEqT$kbE zSyW`_#wC0QW-Bq&SoCX5H}U@DSaORa!?GPX4`#o@nGHaCtnct_v&_Tp<= zVunGq#rly%6oW7&fNaSD+28e%5a4PgQA9&9augV^sk^9G>EWvpt)ZNY*(YP=`FWr8 zeIdJTJl=yDFocLwxk(&-;`wUPxFi;=k~*$5f^AaBowV$_6a> zBv?^Dh2e+=_OTiWA!fY3!x>!@*_sJ4dSPoHz4Ys1`9?2{!mLGW{2eDR)!%ribxt#| zmq!JZ8RY?`U?1oL)956?x}Pv>Caq~edM2P*G*$>SsTXzwYWk^3#K)JH4MZo}9{&M6 zL_ZC>1bC=ep(Zl-@Y_M#kt&cm72FiO|?MdB;@!9~}z?IrE zn~7%`+Rg=jcAK?c+saAnNGp^H-nR##Q2+*3om*UBhv1fnJJHgu(lFGCC|A{PKkYAR zdp-DIti%yh%U!{!%#<~i13RQSonSjFz~E7SFp$EN(BG)j+~Xu-FA4@eZ3$kS*xxOyKqri z`q`r|tmF)-ZH=lks=2I}yLp)=1 z@BOXqGAEL&s?EmEbjwQ|DS91|+B@|yrJbpb2$8|C&q zb4J=&g!O|U^tJ$!1hj-(E0A@vuGyi&=?KR$;?5SPRPlbUhFzUZZZfmfwB%>K-uT0) z;vQ7h+kvg-$AOE}hPgsnTVk4mz^D-Pn1cC#_)s@uZ$KU6-oFV@WULboz7)@5s0aHZd2X7!vfterog_0!>p(H6ewx-^dXQ{7uV zMKGGKeS3R|wr*Dr{zFHYh(XMqx25wI0;%1_c!ZB1H9B;_#V=y zSsCP=I6eJE&c!RMP1UAI?XLn9O2YQXnK4&tpn(@x_!gm|mE1rd+H`Xj+u*iEDZxXzl*hPSy zr}>!tjU~93;kO!I*Npw%;Ck~5oGrRr|LXRp*}7{so0F+y%x)~WHsmJ4&s<^@hNVCN z7%o9T(`kT=3~whU7);6Qw{29JSIL@Yx0z=zn4HQOuBa%kXu(IaNHs^%q3)e46N=Yzf?pebiYXjn!#g_Ec8mc*6=5&7?Q?X}J)V#}s1;L1hE z0>u4VVB34l^#Bo$YXrhqAAK7j!&?lE)SMQQd899$s=S+E5OiSD((?sBX*&mE`$J^8 z<(9At+v^Nx*H3z?r2OKh;hLoH<;&;ab@T}7(zUv}vm6pPJ=jFFutx;Zf1|Vp@t-Mw z=I8{j)g>_KheBR$5YN@hEDA7%>4c;j^n_2_1DrrtPTgtS@W7X@cEP3oqopnh^?uK@ z9&MxLi|4>Q%J_QM04=aKs|CEZ?{{>a^k^2_0w;~%$#EFEs$BbNCB#(oo6#k^t96YT z!UB6gihiIc?Xcc#y$=c~An{bm;r*Ys5$J=6Q^g00-8sygc<;)!+6 zK{U29`yA4}Q8*03#HGV_^!k{PGq76R||#5h>V@6OaUSJci*@3e^3vNW;)j#uQ76o9AllW&wvWx@prn zJ@n~5yV~T7JfAa8p=85uA?iVqLaiG)ork1ZK}d*j!0jV?*sX2Bq0TLZ7On|i+a_$U0dS1C8Y?(U8Bpg_j*#e1Lf zE279@>MQi;|DM13&iiL(xTo8s2mg!;pSkdbCd~d0CY4{$iaM!u%Jf@yD+6F-cY+L7wixKfQlR*%ZER{uHQYd!~IehY!?QOrah(ZXsz={3-N(p_ilj zL~K!?1(Gu{X^=O13Q$227?d`polO2rdgNuJKtnUj=PBljR*ov$nYQ2d80=AfsSq@I zxbKqvv?1p-5F{y(SJOjR=Wrj2f?Yd)hE&^&((Zu*u~QqIw~{MVy<(<39p9*QpZ4WY z_Kp$f_f4NPt}fQsC++OfzWAW%iPPmHFH}a$n1hHIXlj2L5Mc!7%O3>*$L`IkC}6?< zQBANDw(N9`|>k<sx<_Wf6OI{3EU-^pPJgkT<$9s~cG#`UH*D4N^Y8oI@>Y9vA46~@a1__rNhzWWwm-AmB-beF?| z$OY)1IVH8Dqa!(brSb3#AS(C@%2mwd4FJfdbV{+oP~%Wk*5A{ zYzSZ8514-|bw2hkd>dAfba7B%-c_tkf75qFaM&=fFgzmL+g7#t;ciELo${Z1PUpXL z^IyJ<(wX=JJl9!F8pJp%Het^yfUC=KbKPh@j$s8{hJA3dlyZN$!gcv@VMMo$*HOL0 ze0f3Vzh>B~*I>MZVHM0`J6kcrxt2ND4#gk}jIku0OBuo!Y`q(svPV4S-MiGYMs`#> z--sIbmTljLfBf{H$u2WfLyvubL1Q;3atjT_Fp7kAM!SUkm9;+&3S5P3vT?qVzA@7 zTu7&ehx-^JOW{qCK1dew($0e>&Yl`>IY{NopUYvNwb*9ZT68H)Q`Ig6V4li4^7qJ& z@3Yz_KKszVN}C~oh+Nr7`2LM*1Rl&yM>h9=c&GmXb@0E#9{fN3H<}-;G#C`(H%7&x z-{G9w_Swz|5Awj>$4B zj6NTeFZbMcY16BVZkQ|*bMt5DcW43dvD&~zXf<31o{WGH=L*yMtv##~-?buzGjRW8 zW=PjQWZpGOIrA!~x3SJH|J2ZAux|J6evzTwUhR5|FGP6IT`4HYjlclDGiEeR`1U_zp=kIB(M;Mn!SiEsa|NbmSL>^KyJE{#&!65QYS$ZY#SL^!t%U>O+6j~C zX{p%y;LgbK&dG0ubWwM>$e~)s_(rg2wEUx8iE4_{;?n1KqfonfSB+URzzCQFXn0;$ z2?I(sb74-S6Xb$pgXtr*kNf+D*?M+)e<^xKwNL{7S_{}!CM_gZ_BGttdZ9e&q#4DV#+N`D|234uS}! zly~hH7T!xdBVvpn8a2H+V8&13ydq8k^qMi21EYJ*{uK(v^P|Rr2c()de)u9S&({nc;l7-oo_aq(F*=-AH70B5 zp}*blW^ZY3cER#jt(Tvbb?guG2drHH3vK~^R8bd(;RIM_?tqu}!1<8Z)pPe>*!%Tw zl+|!bXZe|dcQGkL2mAK+DeNAQKFlF~_|r!bS#Y|Oop(F5WZop=$IXDyKy6FIno@Gc z(NN{AtSIHXo3{x9zA=!!69Y=Sh5>+*^H>h%JWQDcy%vqgB1&cT(}2?&`AYe>N|`I! z61{54-6}86^y06^ZRW+MgK@V1kah}0CZlWhJ9O<3(LQc=?R_(sGQAS!<@WZN&au4* z?*E|~#g5%5?>xNb5$f#a&YLWr47kp|^Rhu`7!7!qBse_aMSgMPBfJfg z>i_Q_okA3{g{&h>WJ%eJOp-0hQudh2PE3+5%!rUZgb+j4vJ6?X4cST7$Uc^oWtb6T zVrD+)<$M3`^E>Chzt?rnU-$Xr{E;qmnfLm7J)e)~_ITKnNw64`*^Z?-E9d@ex31&{ zJ$kL1Fn{CK{R-`BFY&G4jH~E4EUs_vLNokhHk6bzwmT<2fBSK{Jmq{nuSF$7^ZwJ` z`jOJ!($KGB*E>16eO|NxH1-uaX0JI0!^OaApCys=@41jIi`*@dTnY{8ho`g(X74A8 zUs5wwV4QxM`zzrN3PgZhplga)9IediVAa|T)O2iC4F_gsK4$_6s+C7OhGJE+-@Y31 zhyyzx3M&CjCdL#2u0lL@V9iev#P)rGRjjP5cN8T$g=RR z7Df9>Wgw192-&@A5$mCm5p}!nP)XtJl6?$w)8%Uh8Yx{66r6`9HHMJ1S{#4ul=O|8 z)7RNy^B5)$?3_9@K_`(Fs9LnL2f%N&5_Pr#H95}qbA^(SyE>w7rbB2b`C?w?%=42x zI$@$rzK#LoVfPwUiQ9xy?eyL^&JiBbh17#rV7?3(%6mv zfef6Y27<+7ifUIr;yIu%q9*QQBXR`3Bbv2Tr&qr3mD{WZSASSnC~#Byz@r~|txEU9 zSxNWY0d*d~gFCH{z;}lz_lQUIcM>22#T{U5(L5ZilTWq}eC*3?F{50XT>6>KxkHen z_lW!Y_XH7{9A{o@q;W}2WVRSuZdLi+)AycpZA1NMb2W!EFWNN87@X$Lj;HRHZEUn- z&XT_Z7;3g&dGypOrB8gv00R=AWJR%M#uz0OS!y9hgUz$Tog+Zcmf*YkA*di)(c{`U z*L1<%&1*symUbQV;ox#Q^eA;hHtDqwuPkSlq>Sg5sFfrqZf0?mVe1Cwkb? zW4W^@5N&UpA)19ZNlh>iY>4|XZ5`^_WFE*ig{HBTj39X^0fe4t4Ig4R{4Ci)yVY=} zh1ZzS;da+u82&YhT8w}CcD%vvhf$X{ zi0D)gkJGwxfb|daNA}X#$U2Jxu^Z)VUsQIanp#ty?fV$Y3~3Bb`45W6*F3zZastA= z4kx@_gYKRgLlGok-K`SfA-tghV~9ggIM7S0EsLx^CWNgT-HhVcjSCu|N13yM#<#EK zdec(Uq)Zr{Bl{B%@^&uU_H>VF{rKV^x#Gwr4g4He)cpg;d%QW~V>O47kxR7xla_o2 zlI9O{o^!#6R4IHC#PBX&C_n9Tz-c2NlazMS0So{5Rl~l zyVBMJqy!P*&!NH~o0KRRP~B|<+@Czh`axI6+`@d{s^P^+sqhhVQ*-m{r#|LFAjd;t zjWa^Xo_kpXbuBdwuUWeuW!P11gnImOpfLj&asmf({il|RvDe8_MHKBuvM*q;GPV(_qVMlAr_!&n5VcNq9|RSz4)R zMzZ|zPx^hJ1xR2AC>DsbvpI}npMs>+!qiX)_LKbP9~{UmBe`sA3EN>cKWdtwwYQx! z^0oG}o2tZ9hJD)TVzym#kC%}1JJMP&6Tamaz`IAS*6Th^&5Bl;5ql%f9>2n87Rdb^ ztS>fl2DoLIwFjKtHx_%6HY}G*_FBcS0FlparmVwQsgwPke`0$(BlHC7E!oXdsu}*4$XYv=uj~AV`rbi`qX~kR>g)Rk zfmZCAFnDTPSC9Guw>v#~t$F{=aqsDp1!CGNsP1fhx)r$y>jYuHFlNrh6Zz~@d^HCFLI>eYhP!MWKb zFHl!CQ(h_VyM8G5oasm;^Ia&wp%J)`DWEYxA!w_R{@WCVJ9N|vc%@?A z@-r`mvP-&FE-YHK(%*Ch7tq1Q$C?iaZF8bZ0+yFMbEFB`*`tFW*7#8owznI`-`fYL zepXEm)Jiz^AfoOUg8>UH@LaU#qSfS9cb?wx-ICnn%{`<+YD%*_AYmS$DUgkq&yR}{ ze#Og1WRKS=2CAz3bToLBrqV8uY&FvM;2yr6o|CQE6m$cD4HwS8Crdmuv|6Thg3Qm) zh>$tpI$_}Re<5r7=fv$;4MZRS)~|A|63^#6QlLNLo)O=tFw|N8pw4?Zo2%1St_#1XMcEX1po~74nzkYLk z2QorMFobsNAQ%3FM^6~+8HNkgPzvz<440@$8U}>)F3ZVj^P)N(qUw2BUe}Y%8}k+;b+y`|@#uda^Z}zr7KKvSfsG^RZl4VJyiAPvm8jvc>?UfY zXRe}WTxaXb{PtnO!q*hH6MC{g`yS1O9tR=}!1$iH`MXt$T!nGBrzhhjUsA+E{Knie zRq_^{zh6}9&0_3Rg?S_no_fk1b7H3EvNJ>CwgeER12F{sIC$tfyRaWoBrE~b1?Q#M zqnD18&oBC3lf3n5_;fnQ1yB}?;d+8eGJU6ia_Kr`>Nv`NW_#V=#<2%Ze&nJ5 zE0EFyy8sLgL7P>VoEBJ;+k+Bz3LQ`wJMtr^Nf*qCPnpC{C61^C2YFPdKD$VjXl zM2TVvJ`3G!XM**?Df@!WNA?DNOvZQ7Cyd;Re*5_tynQy{_o*%;S>kt7LDG+$f~4dp zmkH^i!6D_rvc$U7C1ck0^y%|0d2@O}4^b``VuB1_M_TF%hJkQqJ92ABNOKPK9;iLM z?-h7fx*}jD6D-M-2_Rt6G6J`D{4T|V;6gk7o0>~;U#Ns`V%}q-m8Evkuj=luBhS}H zcY1I5^N=zhSJt#&pXD@Id4l%$w&*s!^$!I0>=+eC8-+odRS)kYhwNp^zDtN2<3+Tf zo7tLwxf?9~T%>vTiuZ5=(>1j%8N!$uc)bD@1>;`)L!^}Y$iV`K<3>0K@mCptwJ)`r zd_AFXBh>ieXzM5NXV;JlfP(55h11`pmZ;KH;_;d+-=o?pJfb-z`F0wK7K+T)@GI#- z%Ly;p<)+kHNqzZPLcN#E_BD|#ilzu47QylrFYX@nSW@{wm4 zBHQ!aU6tR>Yqt}+xLB+h=w75`QdN)E(4QmUh63;VEMc3%qcZm8YA&&GAymT+w-;pS zX}BqAR-5xpcb?FK&Eg%zvQz*ZeD1|rDg^9!G>rTL1I%lFS`|oloCF1(hwvIW!5Hxa z1uA5OJMbxiFDNF8q;E(_0r}AdzHNEdCy5K3&M>6Q&R6oie zynqpOq4Dt6nEL8dXyF@WtBU$@|tLqBJS?r@9r|V?{&;4yEK&T_qYGmv&8leh5Lvi7y&AC;2!Rw z99bO};nB*5c!r-1iT6hKY)w~HGD>bH%PMMEA9p2+mFnuxmObfjw?bb3Z~KcD4jNWp zVR5g(!_k2O!`04D<04lZRTIsB#Z%zR15JT@b53}p_~t5`zh5TKdN%jbyT^K8khRO0 z))kLnDkb6nBYW_lS%m+Y|B=S1_HSr{4Lk-;Ff5l`#74Aobrrg`iV&CP*10w&>xlCL zYI2zpm-OgkOfpz<(;wq5Li&0sFF^=UiSoo8NXO~o%e4SF2o)3f-P{P6|6 zilxcy*FWb4!uYiDP9p4Dn2ls?I$(L}pq>6q{ZNCQ-tL(;?x5Osv@#OXqxQ10MLP_y z$`uugme-V@O!}26`gO~sitdzRsM-o$|G%VO2-l6LtF5Vxn!IzMN^>&U02jj6F)vyL z0(v++d~@Q7mYcWWR#T**I{oy=W8yh&oUroeG!Wh5a^M7Cf4trjdo(x3TMqB-X-ktJ`FG`EHQKn8Y*U5D_p& zK>tL61$_v34&!luPMs^Td0BsX(8u3H=-re42r+XNj2P_eS3AfLj^;MD=~FU)#o%Oh zX413;l<2LYF}thDITi>gFO?6k9fD2U{*3YvhbF6T08>$WCwfsxxTi;0Ek^F5!1)HZ zvS+0|4LRSpQmuVNPt?0{p4UK_J3lF?WL(?hrT{bEX$7mBq@F>rfu0+QqJNHPmBN4p zbw=T8&|Fwt@jQf_|DIx5Ke{K+8LVN+%{0u_9jR!!d`4{23s#9UZ~iJjF-vU)o(x@Ms2~(gRfFym_^2gC@FCc2y2N5ILq-xE3pOH< zdW_S%<`pJWIwr10tVcf@i4Jp{UhZ>}k@0+4V(}#m_qxp!_7X#-F$l#{tL&eu$pGAJH&PPY3PUdfx{ z_kDIo*Si-`Ep=fD*X3upzSv8T*A$T%mj|_K20nYTevi*>sQrBTGhE@qnZ@PDPF7E} z7+X-<3j|Lfkr&m2CYVE~47+$2ILme?h%TYN&&DqLdWc(bSs6GejAI+ z@uO6b&pAcXU8+x#p4e@K0xyM`BB%PIl|R7$W<&Co3Z)Q*>27PWZ#}N>?@9QChgrr= z;LkuT!@&Kwt$m3Rg75YY1vQ6l z&`C)24>R2_P`t zupsB*;MaHBBCdKQ4YfR~5Qd z`Nr&t>ZLxq^H@;2C3{2hQ6K@oO*{%e(A4L&QQ+b`0c$vzFEu2Vk}eQ|u{!cD&62)2 z4`X(>Bv*c9`iDe$)9g{ljG~>tGwcv4@h}55Mn-5Fsy2}#Fb3qp)Tjj-P6cZVjLoLY za+*SBZl1p7CuV2Y4qlq6zD?GpKpQ)xuS>Uu9Xo-obO&naM<7}o)y{x&*9Y}^G-I&# z5+(vELg0;TW?X7f?8?k3U1DObZ3z3aBajxs)E8JVRdwccdGd`?4yx_Qe_;0`{C@Ng zrGg?nMNv3ZNpa*!a8oykE?tNy6q_&NRn8f5l3VeR*8z+3lIKOkYnr3o`d%iud_}F* z3w#!qxGf91MHmx!mJsY?K)ZptJ!*-ACMuD}x0Y&oElFYX6Ei^(ec7M;gf3JwcA)6oEqUrQqrw0IgsY_`&rEa1&)S+8#%Hs1p0Y3FLZ9ef zuONXwtO1l4?8QMkUiFX{Ft2&%-=CUVr)d)W$`>~-Yz;66xHMI#F-_md&3^HYEA)CzNm|zZGn~3>5_-5WYSL zFmP}F?*#Avs&sOb2Y|s>L$&+UE*}v+T?t@YM6~aXP++K1SA(T;V|a5-tVvoxK?4vL z`lcqKE$GfaSKxMf_0hmsa-vOCkxT0KfUA{+fU|mQDcbZzIR^=LMxZTtnSm84#7RakfUgR zfHLynr!}F$X5&h1f&q7zgiG0?#KW$gpE1n3leVOzzqOpq)b1eQtIJ?*9o0W$hblPY ze{E$3K06wGxw!Q*xOgk%7nRnFgcIoE*?^|5d6B4&t08;8Id$@t()M{-eO6vuI5R)) zFX@zWVew~>YJ)yEh4(=zI^l<&s1aQP2xB;H5P-1~^8={esZx0U^eoi<1EFv=ElVb9 zO1e(_RV`s()J^i%^QOtx$t#>tl$EKQqT}hj*wb_7r!1y>;W0p@9D4nFy9mt7Y*NxHGC93nO^{!XG;X zUb9_CL!IEWv^Np8%qpGBnRejWn&F){Yh!(X{%?41+{t8}7x|9C?AB*5P50x3 zWx#_3g%x(Jn|ut4aQ7dG5Fm_Z>j?2!Vv9hEg3`O2Pi_|}{L!P@$hpPrJEt^%*PqTU zWf5kA3jT}VC>ZFYzp~dDRZ<}fEZedA+BV>`Ouow@hnyVGhB{^5K;AR$(+tCYS_e(H zO|;XF;9(n49#G3*kBmdTDT*U^yBmAjgZ#&QLnQA=U=S8d%xCFIy={T75+56~L^6v! zW-m|dO#Ju8ZU{b93`BqB&AA(Q;~xTdXVdRTkT zT4P5HKZW^4a~rVm>HYQ%gyOdkjRp_T(z;=st4v?01D^eryi_>W{n^|F z^7?c46UD?kwUjUGD1x6QzC5U8)`%@JSk;APRI!I1d1h=%#LR$+`KDE<8bh?kQ-fT# z4VxGWs8@u0!f-hG_yvLqZ2FfC*^n4!B+xk!_`7Dh)9ZauOUpsPL4cYGI&#B4sP?OX?f%h- zlE<|m8$w}inIDh;)3^A+uDMMf)4uZ~Y5LPReUk?bH=5G@aK|VGpjiZjrH^3;s;Fo* z8Dy3>CO3I0`~X+bqsdeMGP4;(NstvqXfQjJxehE2uqxgYw$JmGM6-6>5&Oj+n&r=_ z&;t=FPyB`^$1;*Ynd*J~BG`c9H4A_w_u~TLu?vM)6680|^enM;%15Dj(+wt-;iuh7 zmjqa!f18jwNq^od*TY&Qn*On>ny+^x^pg4uyIg4Hf3l|hr-%BV{vV|QKtZs4lrJMW z;t=LO;1Do<&&IFHb^krj;nmxtHjSN*-NQf4-(GxrBW0MMB7oVxyR_fV#y}fJo<^M} zAGXtYi9Jy(sgfaz@X_4yvgw;w^e%lZi+HnhT0y1{L@;9upN6Oc#-M5gsw#hgGhcZQ z`D;mIRU~ZXEiRD7BmruxkwwTN=PK4G{T5tbyg$4t@4a&?D|9wKJd%%5ke@1zQ77*c z$J$_(Lz`3O0SjH}7WP149Vc`}*a6{LC!=g4=jQv6iu2KP{FlXE{qUMC zsL&NM0J)x1e85}%p}q)KJ+vm5>JZa6r}8LX#Pi{o04J2WWVn)@HlDL%M@iJ<>+L1c zQ;w8oLGSC(@CCvhG`KbyDi(S2$C{sNX6k0nm5~D5AS-@1YcrCh->KYp(lVE?g(U}^ zz#3q|yesRv3Me*UBM&+=LpGlq?RTi#s(VblNSj{@@U=5qjTY~o2$%@qd&7Cph);y^ zJ{7nkdaz={_CTZIC2B>mJotqk`Hde6EWnw7WbJ~rdCA1~#*64Ww^ujo6s=dyIjfkd zqfc0oEy0he?`Xob(RrcT5t>*4TRWCrDN7(J(9>zjh;zogzW$wzNUB)2kU6R8B2F<< zObs*&gxgRzsTHk40bw&VmVPqK^L#{^o_F!Ww7b8l@|zdDwSr?K4P%D6b>c8Yf&1{8~Ug#BD+)C=98BR6;HFO(9tcH4E{(56bLUF;m#kQNj zlmGESgfH_4%NYG1+>TD-+#+Eo>4D&v7`z2da70Abioo1F?;dI%9xAL4-TV~xrxzjW zHiq9M$}D?aElWzyT{(FsDUy%+8VDrbp$))xJz)Sl(F6toWKPUtRXn$6RNPsgDYs1PI<{fdTd?F&tJ6f3@YC7zJaZoh8#*HrK19^UezpY7~jm;bwfKu?KtC~XdHdga?)@z!Ykseb^5jpBg9s&*D z3m#mGaeQ{&7E&#>ejT%_i)|jTB*5@+g37lgLnFo>I2$Ffpd;vvCD1)wn;wks^pJIW zp9BOe^JN~spy%icL{YB#ldM5b&5eo%MsD5>N=dv9%|Hqx?Qv_`!cYt3r`dR$T_wWdPv_J{`Cp$vtuA<<||;|gFy2~hbGG+8%R z_8#=(oS#q*Eb@(!xEFth(Wh3=2cFU@2R!pQm<1_k?6OG(wA{az>S`Ta{AGEjUvgOM zaPPYRv~V(zv^4iU7W(8YUD&bST(+rGM}sgYK^V$-K9~dS(L72>%3@AL0lB(e_+mp% zP>bkT=gAXuk8dW56Tb}iE`Lo>Qs92f`Qp{LiJsp_tm1HR)^^|;abT$c+=)wuGCf0a z1>c~Je^+MXoU1L0&RsJtU09D)i1atwEUD`JB&Mi%EM13BmH`rCpo5hEFZL2MjGZb4 z)E)!`DU14I3+p@u!ZgfW1I^(Z0+|*&nh{i!qDpLU=qZt<01g*q$$j=CL+Ov_P4>f*cg_<33 z3*=|*waHasfZAPi3CZA zYs|R1&n0$Q0=%E$Y3vr$5Q5zjh$Qn#1Ar@whqzNxE<#p7Y=Ro6IjBuyj6B z_~2y8bc)hGMI23Z2eXTh|!fREjg7x@gz^1cV0<;kW z`!}CKXbV!9B|u{GUyhUi-#+>O^O4K3QDAsqetKrnKTdXUP?eNG?(+?LG?SlbNi?6Y za_%V}^7Pwj7a=_ZGKw>-++?tp=#zo{Yu%GWb@98rK)K7I6o8MOH)QjXf9EXskh@?xWiRzYnS&*#IJ>Kk%|j7|cP2kgxW|;EX3EMP*A?9JmwO{j1x_8WxP&qVCgpvJC)jFLJRfdhdI6^zsVPQ*eHWW(hAs_U z_Ai=~b-vcmmz0zgD)syK?|xnLq$u~T5(owd>qH8UYX~g~P{k}On@9INFD@CX&&c%h zGsj+8ew3^u{`d$_l$9N$XV>*OBw)T6PtVX!gOqz%z&ff16)Qdga~fn^7A_RpPV!z> zC1^$jQkzHK_pFvpT4mlmc0&ect|Rsnv>fUHCKL+5B3ZOk#6$Itq960_T(;!pJ58x_ zxo(+KkKP){mYw0`;IN)bJoV8)8x z)TI`0Tp}ClkAljFxSN<$rJ}V{&)fboZrY8JlEWE~-$P{SD2ku~0Zuzbp;(dc^!*XFY0 ze}1n>A@Y1%J#c;KCiW@1hI&IH*@BL#$UyA+V5p6bec7?zVc-n+hNkw-Cd%+zG`?W# zv2rht{=c5PFu`C=A}K{^+}nN{ zWWdX;W_5-9yF^n1=FSS0F^TL*(UFxg^BC}>&1MU@UHaG58qIyvQ8YqxX@8+fV< zfRS!@lwxZyYrQy}qBA%vopHtY?26@P`o94t_Jz4*7XZaSO}0;YJ`0iTKprPZzAP(l z2K4uFHpr|(+uZ*^8ahz*y6=L?&od!3mS16p*7Fxk^OIGie|LCX5M;vNEM z1um)mDf0B}l-0bw?Qeodz5h-Joqn20Hs=9{Y{793mTH}|&TR|Q7oWC4TUtTlt^-B5 zlZ{v!iblv|9kRK4c++hPJFCetC`iC_1LYot%N})ZL`A+k_cJtonpS@)@99xEKr}CZ zC`cQJ*Ekmf7Q3PL5J?;?UcoqV^(C_GmO(!8o8C{DlWvg!j1l zZ4Jg917pN>`;*Hm;!d(iw-$r=y6S{Y}3NvVKT5^gf%^_9aca{V8a+ zbm0aRPdox0VA>e0*wTfX)QS(q3vTsh6hk7zlD03MwphDno(AC-cc_GTr2dNT?>^B= zadu;p8$p}&knC}&um*q?D@`~+U=g45L3A*XF|tO$?!0=vX+$8;ywd5mC(Mp7;BT-% zu91APep8-+C3Q^(3lIfJAtT6Z0R!!>N=C+T&;hGi3?%K%3|8Hif@bySZ^s>mQDU}m z!R=sDpn<=a1)Fvg4;*ugA%mS2`UFp@vHgXELX2BcX=@1USr|^{&MWrjj$VfXAolbIyarg zAx0s1(|H^15*q`A_Zgpdg|jOOIxNW-&WeP$(|d`4RaYf0~otW?+%JzHa~PD8Ir{^@?Jlj&|lHB zx6og?nPe=Yz!IjwwxUYW;sQ+uu`Zo(3J+N45s=_`&?aVjq$7MZnl+`+1lUI^(&6a~ zb#O{H(Eb4*>`r+KyX_)^!pUYu)NNVJcCaHx#RZ!(Ri@Z{57Ut-jQqW# zQq?2Oej-iT&mm31vK$!5oClSxVkVixd`utw$1;tT1+Y0t(?(kP z$Ppg`es$Y}G}ucsyt2SGNz*W959h}CaBZ0yD~GDK2cO$ToUz8O@{~Y=DhMY44M@+t zA_4tkx3uYn8^iW1<)9FidAFGAS7`>>=I=hLdorq7sI@5_n=rK(!4mGmr#}CXrVYdC z`QGA=EtkuqDT($26&`8!XGi3f$3G%TPBads<%g?L*va=IARhhwgKf-mKyMI}XPtcLl1JXxi1xwsSHpXfePI*9% z{b1GZEtt-OepC~MH;rV-pU;>1{8aU{E{Jjpf#4<#Xu=((jUDSdfGS)aCQl}Dco)k+ z>xUoTol{Ydhqku@J?2D)mz?(VaxG~|4?Q`%&IT4f0E1P!zr1EM|A6xaVmD{NINM1A z41~EYAcRaDrU%c&4E9$K0hG`sHP3-%=hTH_O{M-Sp9I^~T|S&QX?npGsX~y9SK5f> za~az<+v+f{Nww!oa*mGqRC)1b>GK^XKtc(Er}h*S!yZuLvdnbp!oJGX%<}b@ok%-A zI^pfab>#xv+YX;iGhXkNWsu&If#D`VOW2)y;IfN=7b~F^!Y6+s5#S&)z<^J)y@Y~N z>-4!+sUPI@&UsCuXM90gqxeCM_e{@wK8JwfR8CpOq@|?%ffea$oYNq6r`kU0d-{~QvSW}v8MyFJx7WY{#J!M4O~ozi*N?%RNG#q76N)pl3Rj$heXp8gP_ z#ee#V0fcs80|Z_y;B~yIUx$;7_l&6*sxZh&7;Xmj!(z;)IRfit%(8|*vlTf3KjSKZ zfAvVCZcgQC%jv*gObZ}nSU8&J#gO=jE-|el!RN^3#UM?}Jr4gd>XDXNo4xs5iLFkr z?)b=4unW`{@S5@p2GYU?c>-{7>DB`E~S2e2p3SB%k8ikvc|B=B7H2s5b+oyyp^(8Ak1Yb~NpJisohhq|ij7$%deqTfHPt zV}1;}*FrVDfNcnDKcgtI@7EwLkJs_WofxU2_b6@4MWtk;2l>j%#_j?2=nirp2iw(Xg*y zjUkYe;=5fau<-6;XO=tR1T*6q#ct>s<(b%p%60$5)8WP6{_^X-jnk=ZfTXf;F^~wO ziuKQFV1MCCeOl^4GcYM7S;ciFanU7u%K8qU{^-R@tv$@r@&9)kku>0ma6@1)$+`B> zh}JtVOV(Rz+KAV)BCB32CK-M2Y%j<%XySa8$0w21n|zX2?Mp$WygTiY3<(SYmt;@U zP9+kLU-~MBh{z0A`1MoV{XqAmaK7Xp(D!$3A`*Gb#VI2 z{pA2)7+lqxdfGQQ$wUcFy>RQml6D#gJ{k%rrF9Jo0Ad>*WEMC!Xt8>6#B9?pKf|m~ zTzCOG!{ngL*qC6hlizpoJVW+lUf6191F?J9fXx*b8G%c5e(dT+PoJ<2V~=uUsxphJ z6mFO0^I1eX3oyS)VO`n?(sC%VU%Mcy2g390lfQTX1~_3r5vZIf`_)43Ao9?@@r@ebkzH=Q(+(`mdd(~qi%AVn z6*H*`WJ3Z*i!4BB>S$#lw7h%{kEF;HYZbcrxpt*ZOrP!a&XqM#Gp7^(6$AP00TN$` z?Z21V;?bmY9@qm!PRrBkU^g6IaY-_~7Qwb*;l0kU`PoGUjq*5m?xN>UZNa2>>`(eT z1=$00*>D!EARQNOhx&PSfljk=pO$}+8bSoRM69LMh+SCBym&R5)HfW=tz1#;%|%B3 z={yi=7&)uoHr*@n#-I__;2%0=uj{c6`lL}5_I;8$H3jBoxE;N~qN(DB64rRQ7^}#T zV!dI(8@-6B>5-LJl*p(xjrWI zcMF5p1~gXSJ;#uJfF<)KUoIfSyC-dmqDljt3G=ScNtxRB*NSe0Evl&Yu1V|WMhbH4 z(mOEz8TjuuM*Rpd^w3OgmlF@AZ4zmjH8IrZ7?3gwLjbZ83L`j6J2m~;P{cnFw&muu zZoUQ6@jZc_Ip4B{-dD+U28pTGX;S0Tp&zSCe$i*M8q!V~9$x(o2q?0D^$dm8)%$%? zQfya^-|bzA7=PX0@7!N062>MH10j8+odbF_3p;Oss)H@9zc?CfOuR3ATCaU!S`xq* zJV&v;J&-Ax3=j5oDeA498(PG>&)iM;{=>;k_r(txj$TIk7)lDb&=OR8E9es)>O#*z zJB3wu1x8Lizm5*9E)@7D%f?je3%+wwEqm$k8Uo8g9Ruy5 zf8PHCncRgQT=qCz#e9MNn53~bQll0~*@WOs6kacv;@Di>?9tmS;I?EWYOUPCg1Go4 z?Ndeum&@Bl#tQf`1$86kq@%FL&ke5uBrc$<>o0n?7n4+x#t0uKHW8 zP_$gL=#vt@c3nuB8{8t{51{d*C2-+TG~Va%e;`ndYq(`?$I-)R4+(9&T1^kZPVOCNA4AD3d4iF0|jfH}^Eao`5)W1$hZMta+T8Q{!Q$*F#ew zB)-@^Tx%Wl^j%_ov~W9e$*vG+3T11n4Br_xXgl8^04HB?*<23s6=V-aktLPJ)by8) z1=r%fb>blOf5meyNzu*xSFY{uhcpgodn4nD> z2Ss_uubuUc@UuHN%&1+@^Oc;X+ zSxblUu#PIS;XT4`{lv8AU}lGpzpv1ek&m>Fte01g%I(rT{a#K+A9lt+4)9iZ?LwFF z89Q+P4AA+HkRYCKkytGbbSbdEbMUfUV(!iJ)J-=}F`j`hRUN08zTK16KXUopaI$g= zY1NsIc4qL?%AV%s;Re9v*-tG41ai1Ydvzk{*Be=cPq}Cl?}UE{zCn;RSs$4VG-i#& z1PXO^jZJlLX~i4fS8`JfJ^Q{|{NcAYwx1WVNEoiinA<&_KM~LU-rxhg@BOq+o_+g+ z$8(uGnr^p6Qf4bVmS$!;7?7G3J%LISQje$d8Tt;~70TLOw0aEt;R8SHC77>>K z;|3o4tvxe2YsolvWUUeUs#Vm$!HqG>b1Sbk-8k&)EQeA(doPja&8&*pjT!S}a%obz z(6aw*qCj3nwXX|He^-(xOUid_2ngMxee1CyZmJwaH1o0vT8Op zV)}I6a`|abm1){4Po{v|9X{rdul>?YPV$LxURP^}(D?fXk@cwd3>tXG+hl3~S0Mdy zi^eK>mmopePcxmpIy2YDJy`r^NB8uk;~(*9E6%2wvwavLfat<4QC^0IYk7${YU;SR zes8tTvJku8CB|cKl&9<1OdG&uF~(3YfIoCX;Xo)Sgna;;#&%+PS{2_ zUNM;w6~(?KS<&Z`wph|eo4s{&^i596b`706!vmpM0h|3Lp!6PC%G?Oj>&EYUGE}p5 zXf#r!D#2qQDn}oW-{ZrDv41t-=wk8ij#0ZAe-Coz*b1r|6H{wUM?n(qRJ~ufJkTsJ z+NirbZ9jGYT1!`1>{af7l^c9k!Y|o%kQHFpSz>X3-|&dJhG2q@nO7vcEZWI0$%oU< z1tfsnN80natMll>KIh4Ov)MiuSyf9Wnxp(VyS2|kewyYP`NrzNW#h)t(}$hVnyB!H z#QJb0Y*Y?c?A@4OcP9bIP?5XgLl&de%eFzb>1oyiUY#b#)0}31e|~ao3rz?@$JFXk zKOmWZdwzh|#qvgYA(Go>VN+&{6?6O#h5e#WiGFzO7(u6Y9Lp|q@Dx1H_b^;Enh%C6 zn7;%RdgyR-6-=>71XwLH{2D&%QBkxs!P;sUB-|SQf z6$B;W@L=a9F!8i0391 ze}R%!5u_pVl!IJ%3W2vP^ZAV@1DHXN++JjCkgV)NsPYt)tei#qMMu+b!P0oH^8N!k zZ^HO%kb4)=g9VTuuY9Or?QWT)LOw_2(jVP@7lOHKCu&%(Df^CX_igyir}~1IH{j~_ z)<(s41`M4FT1t8zJ))_OnlFWQAbS6cPv!r-csOQ(;ABiGRP9+H-KvFB)qn5(rTgz6 z?jEDU{#7;`M73`sWxihoI~%Z@?OySfyJ5s@O!j5ntH{|uJyAEwweG*B)W_pjmMqbH zY7-(W632PtB8*?=;2wCZc}Hq80)T-UY`$PRq9txHEn{h;TwcrglzWa)+QEWRW3B$y z#ftov1dnJlJ_m;<#6~a68&%w#GM}S%259G;sGq@rMeGc~5l(ri9VZCt2AbeG1hy6* zU_)s=KJUSwN-GT%&OkX=&)?VBk!5#Jf33ytEWVj=b~&E+eSv!}B$uth;}?o7zQ1Yr z5!DV7S3rV^1$lZSa{|4@>d`*Ibull*fvd-TpO^QpU2hWNZ>HM3U%XvdI+I|5Q=R+> z7z-dsE@8RXbJU5tg%uu|X?bNEHeKWC^#>7q9kX`u`}$ognbv}7fkM&F&Vb|ubi6J& zq0VCkft)OKhIjE-R|`sqkQnw1^U9ugY-{?Oe5Xb0xzXL-DJlC9uw$Ce-5 zXn24TjYtaB3<5@n4;UQ+Fgm|TfmsH?@P4jn_ALG}BvIJ$Wz2R^D#q370|ThqI67%4 zRME5RWAIn8nHaJdiXxzVXc{aIju1^zA?|mX|y!(L8N%D|4*#5gx)5f6cy|G~Kvbm{w zo7=;)Hbz2mUz2s$wZ%#y?Qg=E=zZ+-?k57<6@ex+)9A=E?JzGIfc!5zuD1@;W>)?# zI35}`zy8~5|8t=piEm0*>(;XYLN;C%g{_UrZUxD1MUQs$H?6ay`^0k$BtK(@(PU-+ zE}P;gEUp2|^mvzgi`G4Mziv{tR!VM5SZ$AqSEn`mVaB}|;*VS(zLs%hj8W;n#}U-M zKyWw4*Dj*Zu4npX5qb=W!f1!1wwZEttkPT0J*n@ntd|Y898%5OSm<9R9BVguDuV~1 zA)3begPIR>M3beqBWag|9}MpfL0n;Ma`KKfwO7k?N_{Z1c2oDKm{K;Ydn*e()b&U8 zpWb>etwIWDgXJFlr))a_#bFaiZ_Sv(=|c+ru4D%WV)ehvQuu;N`)QVJ2o09I^rd+$ z#q@FE!;?haJ4PrUbp8HjpYNdyvJ1t9ZfalpNusKlHtI!_C@Jz~=*x@bMje(M)s zdoQELE^Hv77=)kY$k{ah)J~4zbCoSId^SoAQQiDf2~SQQ8^1Q)IH-E zON0WipUK*1abvlixS=x-pvlk^&A@pGENw^Zm2;V4NA}`nENYuRwN^C*1!Y}HHg`GJ%;iIBMI4waTMM{pf6dL8TZt^RkV?KBBP`z<2aHyYddqlxmy3m z=1r4h{w72GUP-7sz|#B%F*T-}jeNnrim=cFXY{OD;9RG1)MV+j5H|N;yZvc4>syU2 z`PGml`k=tm@lkoNn7;@Fb;`PTYz&biLs z`v-6dCUa)YF`jxq_pRsZ)gb-NeEUO0&h0oM22Z!{w!YeKM#@Ey^znTBPUkTIAt)Mz zq6Va_Z2hNbt(<36#EXuWCXg8UI^68(;UATB-@Y0oX!rnTZ}RZXRQAaAAmr2zRT7vn zCTVB`yp)Mn1r$hq@o$QsLE{0*1k(KT^4@a4XYV)YMhgP+W8=Y!rpf`7q_btoXVU@+ zsaCI-tsqS_7RS*YJrvLmqb}6+Irho8>|A3-m6%$F2p-<@NU_>@mAeeHgmzIRR5s-vn z54Ch%aGCAqsQ{vwv^o_*fiZ_lw2FPfqS#n`p#4!GzMJj;zg;xqz`usO9|8poY#Tfk zaUDFu=Yv-1`n+v3-?}XcP7)q7SATI8kSBtp0=(osnEZFCG=de^B}SYkwFNzJ7TcCg zljO-n?5tpb)CguHn9eN==K8EM;XwHzOJAB)l3o>&eC_4T*75Sjrn?hC8ezX5KYl&P zdwcLknrYwYp^FT9c4UkJd@7kL4)SB>bm-&0KOGdpd3%MZPGnu^>zmb@Xcq(_4+0upRTp zsg{Ux;d!#t1yc3vl-F-?+9wrqqwIRoxV%yhs(H}9Kh>K2!mBfmlvx`0^zFwvYOad# zZ>N3siZ-6NBd$rNOKNAU6eJ1}g;(vcfA%C zrYFLqeA+&Y)YbB-4k~aavzim5^VB{+-zawf5c`!QYNP3m(-B6xn%95lw_P%B9nqk$~ zE8~9HvK-f!iIhkDn4CwFuxQ6pQegXbSsf2mgEb?IJJ7B5^Ixz-cHdODK{Co*0o4Mm zzSaV6ttBguCH-0S1@iQ{CljGurX!8{-~9btzKM>?m>Ktyo-5YT`h=+M3@`DwtqhTG zlegpck5i3iu%PaqBrRHs>!hL%lU`GnzSTa{tc|I8HL|uk`FRw+YRhLS@F=6o-vHOg z`f-hFq_ppap6Y{U=rk~EL9U^JjR*`mz7{Tm#h0z}L-hnix+9+K_qm@kvvW(cD`igg znZ;1YL9Q;?RU55eS7HM{NaF3jvm@UksA7zyt^r5Q?BPif=Pyf%?Mb0?Q!O;z%LRwq z_L`SlH!tQi?GG|deH(@~O5Fwwj`M$v*XMLPw+9FcR_SloniZ&mTI&SFt2OcW)H9^L zgdepQ$%PhaS}*53y5dhieC93m@`B;bjS3oZ%DOSRy9OvBmp{lM2WX(G&IjNMa^HGAxtDq#qvW^sy8p+p=D%ea6rwU%7fH?@NisgA#Uf(sIpH(f3p#>T8^A&}t;a{GpSb1;cmThfT|N6F` z5$&VhuiK|4r?FlDE%ymc=iC9?bc6Pk4!RP!3*9lK&xExB&b8!T5hzN-(YB{IN6;8E zeok^iZ{qOD;};&luEU;SpK;Iu2|>hlvqs!&S>M z1)|i&;)>%xJWhQv)nGspvZ<1p`))w%F|lGZyi_c7N5IiL+&f4szq;R_awq=SxUr1= zTwCJ^K3<8UP3H#PwD>NnGc66_frvXnRh}&alZfO3iN_Y!^M3ctBcGdk3*GGnhsV|^ zGcw7QeOa1CTLAeQuzo~y)=@8#3my`7c)SZc-*nVpd-^mpv(m)j+tc7gBkNJQg&l3G zv@FSDa1t>6j_K?|Xq!9}l5s)B)6Kn9cCV21;`Eqf*Rx#pKT+Bq8tMsO@RU2i@T(L6 ze>vz&@E?Kt8h^pCVOXjUwgL%ht$~TK^-1U`mMY-0Fj&g3&^il5fnrdfU(I|CQYbvq zP(Y!)csekqZ+%ik!oG?j&A(4>flNcrg;T|7r1k+M7ua*T!B)hBBA2Gt$#$8lKYQd> zUb$&gRDIp}ugT~=ct^OW6mq>ojESG&_hXRVvhIQh&I}d^2pRZf!s#5^WOTniCzM|e zgy*rb-B+q3tS(OfhsgB z$R#ohw#N5rwXd58Ux9Z@zC$DH^|B^~SO&RRbp!ay+<)N*h9kgT+G9ToBU{>Oxfnt8 z#*?qu6dn2+2%wR>>8hYBF>05F_%RiV9FY1)5;q$y3Mmjle}Z_m5WmA15oJTRO4F>4 zu2s>#7(1c2ol(MS4CA-Q1E>{j;tJ$JnsI?f$*d+MOyZBO2_Tsy)0H)B)zq3Xs`C0x zU=56BWx${ihg<*}E(>(F3IGI>Tj=xhf7aGM-+KaTc%wx&O{p}Pgq=cf?YoedWnoTm zjW^}*39e>+`ch!UKua4}QX)dSS@;ah?iJ3K4iv3Nh94uSDB_2oFY&qs77Fkbx%F2l z-Y^S}2Qx$C(d@J$eK_j1K41D-C+q&>6Nh39HI5ecS=w=_?Eyv=DgZNge)Ze4mpTTDz$ z^QSH7xfX&uyMe_oO{SHSEPbPP#3L@OMqhv|cphr}u7BMAJ1ma>d-0;(sce7qhiVDF z{_lo%CUod@f0gyF_FSlJmBsf$$2aX z)jGuP>U6#91U3;P`GF1eLhSqwy_wsuLRlv+!B{^C{g-FWjKZRE`H0>7Yj9APnnnNE zsaW`M@ZWz4;!oP20|1PPlpi<)nIJ;qLrtN1dWE9pK!7AXw$~p7VJ^+pxtU;la-C|s zY}=0*d)QxcD(n-x8I+fJ0IPr|9zrgkqi3-Wq4&Nm`~}OjTSk+jM1bpeLcK?GQvH7) zFVmBa!rOizxNRDGp?8-$E&0V*M#kUZ8VYlI<+OLG-4c z+`q6v`7K>a)*YN`NY@-VQQEw8A(S(nwL4ACjN3^1`jz~v(IE%0-2e0XrT@JCSA}h% zeHE(l3@`)}0JuaKEX9?`qz^1fPHiqF-Jj1;Ro-&4@9q@nMn>9=6rE<1J>+!nL=bfk zEC*;)v6O*_X-7W+z8h#vrj>9*tr+wxWDq{&#cZ8s?SnbhN~h-!9mTX@bu~A}9x;CB zn-tm?Lr(5c#W?@Cc$aGO@{owm@$vvdjLX9LnYz}~hm``tgSZM+l)Z*`1RZ!Eg}i5V zq{mkM4ZXv>!9b_m=^V0T9y&WHc`!jUKX;O}3w~Rrc6$@O^wc_QWpe!#6 zw+}xIA3iBJb<95@LIEH}0O0h0JhgwV+75y$jstr@9V!Rk1!x7)p#fw&o9qF{wf+^C z)i-O75wD!T@?De5C0;K(=B^am$5jpcf%&M0tz3iD5(wcb-Lm^fqMgaY)xcY#l8G2z z2dTr9h&1D>ltcOQUa9+IbvScL&#Yk>y!FRFsSr?BV3p^wBpXB;m@WGY7K`QjfxP>> zGXi-P%CAClPJ|upUtR2XquBA`UJ4>lKJP!tw0(ig{|J^7Ff;yZ+6%7PBzI!kK54YnaTE)=axX7G|insfy zN)?+UJs&9L!s7Fm>K9T%oX?pg53ZiUE@*^x-5sP=xXz1*um_9kJybAAW&8#ES3b2&XLH{7jcBHGu!3DT21Sw8huPf#^O4C@$~fmQzbo$HR^Fj1 z?T_prhk+X@k_Uz*?Z{EcMOLbW$R9t;hQ?=LIYY35UY$t^)z>VmzaI0A;!AT22tw<< z9kJPE9QxX?M@WRl{QB<|U&xoG5?%xIVe zV4JV+YsedY9}jW8e`NZL&;j+60R&hh73BUYpAlad(BI|Idkr0>kUpGl0pHpK3Tb}6 z00y{ZM>qqtYD>ZB?DEVjfF>$^r{0ei?v@CsviI)9M;xzHy&~Yf5G2*y=L9q3+hxRX zorpNiIE5vcL@3bj?tlh5{uvMbF!oPXc?$FEoI(;|bQXiV@LYA~;QjyUur- z6hGexn?9ZYq`BQp)>G!dpbf(#nEC?DjtJkL1hY%Gqm?1U{mT)}uTavo;w{v%MaY%> zqgv0jW;4k9^6Dh}$uWM`#>WF*U&rd+a56luIf?AZryc=d}4xH$l^RXv&iY|@0+DHPD6B8-{lCuWyPMh zCaL3O05ah*0ZA1EMrgpU0BrFK=zvld@KY1uHF@}>t;xNmk#!qwhi>_4)OzjdV~?ZpUE#*7La;)e$c)VE%Z@z8DT zzyJ;JGM!T!uL>#Iy}}ASPoi=v^uzffaUxFB9iu5#{GW4Ofk7fsYXxO6S-V*DtK)ynW&9 z?(}JD-MVf#V+P(+Do{yl5|2%2*?-xf0(w3V6ygSQMx_f6bl>7WcR)lYUOmTIQ;=a7 zLlp)J+2Me>OCGtEAGD4Ui$JXp@o#FUS9q;TXY)O44*RkjdHMCo2AAupm51+~68o;Y z3epY0vUy0e1NWdr!q9~;p?9D3g2rFMe-srw47`OM8t2(7LSI{JTu9Sbbnw!KAFM-( z^am@Xhn&p}=T4S9d1f@iJxy-k=%$dTgsk0&$Gb#0GF*?jA*w>Fr*djv|4gY zbhyo6RJ+64?Cin_BWVuTo%L;#Of2iB>RvF*#eQR1$Dwhp@ZGzZ<#u|XV=4WCe=X@n zETRUqrdX*`5_nLyRaDqED`opRnWU2p4yvH<)!g?Helyo}J~lMT@mZXC&3~#jcDWNL zV3wF5d-tBODPJA-xxO3}QwEJie4N)f1HYOpv@EgeFsy5$gBNCH+(3@3QN`DQ65Tum z{{fJg z>KE#1?Nqil=4Z;1vre?N?t=lzB!%({d_%vCScQP!G?M$QmEZG0uIhV+{fhBw?C^6=vwu4d)_Zh- z)o3dOwyb3BMQM8P@rZY!>@7U(%N0F68Tx*MA9hFX;SO-eRTi1e1oPH*DH|{?5J9uj z6rlF5f^6rZ>|d~+(7kY~)D_UD)P-9Af+34U=q0yme+dBPBu+Y8F$e;_kqW88`fy?I z6Oig9D105wRYgGue-nS*Fk+h6M-d~1n5()fp3 zjmZV_jdGvXC{gR24c+Wn>p$6*yUe)k_X5@WTgrfM#q8~}*+|!lO+Io*G6X}crGOX5 z=YVNWQM5mgFd%qvQ6m>7Qavhtn+N@#JFQK417kQEA7;~%Dp+MD*Rc9fnNzX}w8?xm zcPBxC`f%wMcHba6C?KU5m|{ZamBA}kl;YalY;mLoj^9$e(e75phpdaikKVExg1dVR zk=L!3g6Zr%B+#YhQ@+_mE=}#$4hXIORVC{?^pStZrzTN^ujZm4Oz;8>Z})Z%d477k z<&4_u*x0IslkTZ`QJKU8rW}7bO7UQyap$|nB7nKXf5$BC5ItX6?A!$yHlVqE#8C;A9>$!dQt14tj=Y;fs2WifJw`}|D z@G?soR#s5-=~9|m)`@}W4@bxO8GbScmbL)lPy%LYnSvsR_ajA?V`}?*)n<;&s2-@% zxR-oQd1~jXwX$EEk-QmqT3`^exM-3pIZ=a}UX|@8fgM=5uT8xKt%XXK-f%`mDKt&p z+7WHT>Be3?_0Yg?dYm2ifuo6a2ni{%4o@GMrVCRKLTZf-;s*n4MJ7)FWI6J3_!Agf z$f8xESbX8l@NL$0bh5sT{emOucvob(%Y#&}RVVL~BO!7JMwY{su6vlB`t-7f2BNI^ zOz=@b=efOb+kyOf04v(<@HF})xiCZTs$V9pJ+U>hmFs$I(mRgfAjzX0S%Q-jqoX^> z$B0%>s(=6`o|bYsviBHB!k*v#vFye_S~KqIACe!5BF41wQpMWzH=F+W#~o_ifA}SgURnh>X(!3>L=CK{gGvMD8DJr4@t3#9s7#00 z!D4ate2@6m7xL_}q$gB?zhGy>69G~hM2LLOwt}$nVy_*)4@q9rUN2mayENA0)Mq1yE zl<{8E7ha?q24Wi35>KbC;OLfUA+_Yv_A6@ECt`5>Y8-| z=(ZX#=2}U&`lJCGu77+%wRBDpYcWTW6sY`mVB{@SRS?n%NLkv;QWc*$N5b86a$HLh z7wIS>ekA4}GAl3}k#_1DnTjnBABBJPq?%ZRE308a#7OV}4@6O^2e|hS*bkPpB0wgz zH-P-j@(?*03PaySHX*3OQ3TcPbKqn4&mp|_V-Pl^A$*6t|Zzn;q;8!NKJnP#cxLIPWg* zo9KGteA0CuiEPJp1pXd+x7u?%?l0Jl6*K_4h#RAXK{8(fF6;u{CZ`(|$Cv}5{)TA1 zZYaDeTAlnjGu@{s6hpyM<{GMf#jMpMiK{${M#oEK4{=Exdcm=<;SAI(NuJ5nr~C8| znNEMf&b48_*2Hp9sbI~&fMXR9%>ds15oz!459e^gX7p9EXNDL?MT5OTc<7CUS*mGA zE&>1W9P1%lVJ)`;xhh!u7b)~91PSOGJhtisEc!yK=pT)jSb(_ue_zs{%^;47e-nMU z8~nI5y6?mmx`|9~(+Mo_)4Y{Bd$?Of$z53};_RpG0=^?g*OHY5lo9E_X{)43YSji& zb)Qp#{w(YV!&|^vTl&)%`zsf*zko!6dlo?8u`c}JO^2^R?Psl+zQVbqg9)eR>6V9n z!nb{$dd@tQ6=4rKbeEOCZ9cVv=|D!^ufWm~dDJv`ac(IX+Y~`;86jz>oG!8c!2BP` zp#BH`=TE`tHOQteXG3!BI4q=yjSGp9AQt3R0o%O55~J=otzhANc>n}YxmQhDj-BYr zxgT$921qxC5M-9*twO*NKAEuwU2*_gU{a`Npji^We+)H-Vy2~`n7j}N>5^Z9E&~li-ygX^4} z*gSGobq6~y+`i@7HQQ2I{Zo>g=PB&ZU`wNMz!IF%u-dRxW zXpewsh2wXM(|jG(^NH_L`f&`=Ta|zBa&M9HR=hV;P`+8hY!n(l z%sE;-h;51KC^+X|Py)|!Qw~!SnKitw1Aut?{nN4k0G{X}DRtn&DO5gMGUi7VG9AI+ zt&aEYxpP{+F~Ry#s=qZG7eP>+nJF$8rXb_VDvcz7fL&E#D~gzTXZS)BtxS(m^IJub zRDGxBKu&VbqGJ5(I6Jv4`&iw3O20mWhtv{q1b}+uXQ0*7^0{W)KhB7`UUj{C;+Ctc zVA!Y}`F#;}4iH>a&^cQ7^{9-rrwBJyUJz@w*uE)#O4Xfd^I>%^`sml7ae8>HP*A8&Av z+Jtjzj%(Ce;=sf6F&TeET|2@0XjdJXAjm+gMB|QP-O=v~39MUsy>RG2Cp;4+3UzIB z*7y|i2tQRwP4`c;?Qu<$dnEwBKT-qef<^Oochb@LHU8p>m)_#1mT$nk6gX;`Y@4xVSrX0O4Z2}tJ6!uhpZNkhNGzAs7QPupBo zWM`FVdKBbB1~U{?NvB9nATeCLzF6cSzZ?2eHakjq@yXcxMCpXQzMsI)ekTl5h4(0y zQ2Tn1LchKgw2gPRxVCWo@|&SKf4R(tBW{TXZ{iFU;&M-8YJ4H)_(&B`bJ?&gvqd$X z$90vr0d*~AN1YOr{~Y4Pr^?+7k2!#Q_~1j67WqS-fmG-@bd}*#d&|&J4XydMsg`C- zrMn?V?ZvUT0HPVEaf8>Ngb0E_P} zZ5k?$((GPq5s97m4flG_{_NI?Q+g?+NGVgHE#!;m+Z&{C3chKsK_Ac&k#@2w-d39J z3>BaBY(1{ll^=UcQc}__UcHDeviUQQ&b8281PTLM^&I1e`?X^=kK~>hP|ELkv1Ilo zC@=d0pgg#X@Y;ctRJYdlWuq??4M-nda6hr}J4Bm1cV$GdZD6&Alb4(2wiTmX z9CDPED%7y+3*gMz+sEXlN7XTds{BJ@i%lGz^$`xMwnd=mA|L>A~Zobw*4`e zy?p-GJGsoX7kp~h<4?21B3`1G(kZVYr+pA*j1Rztx`WCfxY=w&@33!B^#!|awQZGF zA;X(}Vsc#E>KPf3DbAN>bUaOX2sd^A#XkIK7)-?voGQ=6wNR4ZR8e6aQYaK zUm4Wa(kKQ(DIi+6J;N)mjX|nOi zvTqX!m~8>mMZmcJzw3aZU63SS^q{jg5lQ6>CM1@Wev0hnDYbsP6VCI?|M-WOrzr1& z;hV^E+dp+ekVcU%aUvO`Ey>TfXvBl2qwVDD;(JNc-jALy5uMw4hn|19Ox{LcJ(=|q z^X0`q#|1I$;G7rG-!Jh11TtVVUKs3&bnDQUd~xjQqL^OWT&S2;q3%m; zF?KK=E8{5vc^-h_I!Rb9$~sPgv_+Hjp;Q3j1(%3pNg#AxcXu+)QxaTYK)H5xu(8P< z8$UYE!%qX{ApDA@)q49t6-pbmB7M9EdPhG0?bHM|`DUkr(bOS^M^0*GwxQ;7H+cv6 zVauufk(rJNlD{cc;QN3GN^B0{JDGI_k=mQw&7dRxzsL^jW7+;*gcq+vw938z}hOmIqgthA+76`7QBA==vEC( zs2W*>ZUe;$DcyZNR6n9sx}JowpVB`ZQE`>DGP(G(cAH~pZd>CQHcjT{%ghh%dRiik zO>ToIpyLj;0%w#B=j=wTTd{w&_MuCVjWZm99;0}^Q;OWBv9Lz2=d8^32P%0Dz!27# zjdotsaK=GpU%A`IOid;7aUgd|u>Pu(*_-oxGCWixjMCQBGGig@XO?5)ka`9N%LTsM z9JsI!gvW{<ehq$F-Pc8 zPF3#h9j!TF=2yhmAORS=5*_oWBx4OGJA1Q{_cZ_W9}M6# zKSASB+79T!FPE~TUEy3CcZLT`r6F`*C#p4MK8)YzO1^K0t;LHcN0o6l?)Oe~TryMc z0J%Ao!b$=Dup)s3r%{1@&s-puQl+~h!S8VAdnJ}zUTAhBxwhTS(xp-+YJ6@y;Eugl z!@kpUhl3gnG-$?+=C`O86^=fRek*{u=_~KkiTb&gT4n8%I!Weyv-ut#GD%GFY=`UZ zUZF|1Xdf2VNaxmKcY$HidFPXp6sCN(p((ni<4k-kYzqtnc))}p(|r$c?GvDp^BcKs zy6zQiM=n|?U{VV&ht27oEZb~y+UV58F;pgr_6{FS4ClXae&0>*NsZ!N|` zg|snWCh1&p-9^ZEYwH6l%9&jHhi15f0INo8%R8A6vA)R$W*F)Nm|p(JRK*%q(3j4h z3dlV;@=3QBa9AOYsM?etT}@F>eSY7n^{e@^>k&P9_@*g?ye<1-wHGHi!gkD7{y+p$ zG!X9C-`vqRL5(&Zv_K#0 ziDE|#n%}$hvOd;`vk{i@V@r$U*RX|0U}Q@NcX3qR!{bCC8{Rt`1jOAlsesYeC#=37v!?) z;Plh$x_g>trev%WXGdG?MVQbRWDAf^KvQnh?&-^86HxNeE+AKrwiap$zwKR^*L>}q z;KM)R#7dt#3l7IBDpOkS`)4jI{^jZf8XSHb+Ecj_?;|;=qfqXN>65NPnil!CqrkF) z5FrFaw|>zg;6G2G#?dW^K`A&6#R)CrA4d&J%RE@#Sc(SWhs$}PVpkE{%LB*y-q_>0 zav65P@jULimA)@apMtYd)oW3+y@y*#QoVU-il4+l!QQA_bv(X#sp7#0HKRo?n{o~V znQ8^Zh*f{oXv3B-sjMYFBmVm}Ope{JQBLwOk=HSkiHYouHv}~;BQ7pb#mf+Wr zOaMWOr*Ag5aESN4jqeU_liCwOr9ZodODUPSc>_5v(F2H7aoQ)+P*VgL1jO);QfjkL zhBCMFe9|KDkrO)w-=My|vXeRyG##ss_)i!70mg`~?88yRKsBR(O=J`OVlveab;!Kh zrM{q@s9XD^cNTxCX5*&4F<%-Nm2eieo$Ek3YPG(L^#NMBx}$}Z6{yEwK5uIqACgn? zK1^7If zqkRgZtnfiFPb?CtRlaW+@zob7qLc2di{^SoKLQ~6#~j9c4+G6-B2@`wZE4+;aWan2Bc@t z6!&f=fB;KahRc1OQN~lwE@KIeLm}LvHmeBPlz5Vax%yoLj|c8~aiiJr$EZKU0iQ+k zEWnO+B+VXF&SXMxw|q7m=dW_yyY$}R3;R;X&%Pg+QE?dkXYV1ZC#m#Rz}?@4S>5JD z6$MjLSe|RBZjURGVEpLn@;P?xEKy#Ql@4WlhFk*#z-kN_Aeu^PlJHp2;Qj!D34iQS z5badz41NHmq_wT3?a)c{iXW4grNg{MEkDiBHfL(1f ztjMH2m8x>s$bQS@Mjgv_odai&D)df$QQG_Z|BMp?^bR@x|N2rQ3($Gkz=6L@Mbh#Q zLFe!RNxfPNdaTc&UJ06=P0w45eBcBMTt-3-^!7bdPkf-gEr5iUN7bd7w&8KL~1g@p*vN zxg0FvU$9g-r~ct+Fe);@!!=Cs*0c3hO;!Cgr^(4V(yelS?QC0-=!w&UURNtq63 zh4aumv_F)ylkHTgpI}ER0uA;v^aTVrU685=B@&UzbP;LZguz0!hyd$S3oN#~ghF2s zxOVpIHixOtQ#YpL8&VHWvNtmRX2;wI8>Ja!-NhJ4Q5)beR1-V>yWq*K9mlWF{iuDI z7usDIwx1DX8Eclg$Lnz0P6ofu`s*kXQbcd-WdNvhz;G6KB$-l!ru3k%p-IMTOHqSZ zKsm*~PhcIuNUb=oL0tnyHfu|Emz+|BznUHY+|GX=neQ+!baUyzHNPE{W?75vZJA9R>qEIVq$Cg zqXDCalpWcJ@7u><3`R{CR$Vvy&k{~?KmX&FSR`+KEAW#&>9uF=&^48AwbbXMkwcm3ESz*V+IIgG#;on z_}LBe06gdR11zAXhALk9YNu#?QEsi*9W_?}9aP_j`p++np z(C6hErd4A!H$_8L1)|}NGa&Ro-f&~y|C?NUN+b8CSgZ3^ZLY}eZ?a-y6KLxYFz*Pb z3OUi)yrcGk_s1PYiA6J#tkPwh7kW9CymKJb&Q<4v-(3#&61?py;(5~0yW-o&mKO0@ zN~kO8&RdV=_eo$IQP;rBAdrx0Wh1 z;N%#x=XGF8Bu_dgu(&MEz!)?Gh7g2Mv?F9q#4t}hgUA)Vv$v?mk(&3HVy1_31TzM? z;?2f}>hoIS%|2lxtIBvji2!7vSmM6951O|n3M~@ROz_Vmr24=0DJ=4@4GoWE?nQ;A z9ceQTA2zV(TKF-658xQYBbmK^OY)m*=$y&WIyrho+pNi?ihWq@Q1GA+5N?S?Y)|d0 zIH8siFMvkD-Mu@NOOpCJQy-@%sMe|CQ^m|kZ$hkxnAA|D(5BG$HL6fBC55cIRSZI^ z4-k$lf&1>b3^ox;s$|cyhzQp?b#tuhc5>2kmyEO;i=pHt*6-^eabdZMntQu;p^YvB zX=`?Nu~o)|?P}!&Ob z(Yabk*iSuP7V#{ivcUUk?$MF!H*9TffkOIQp1v+nms!|0tXiE2QrH$FPq~+vR^~m&upm%U6C6-vUn?)ND{QXfG>vmuM3JX?_gH z-9c4ItyTw@f3*|ojLnkmmODA>CoUXzsq0~5SNn~omcL*Ypt}FjiYf^pE=;22VX9E} zfIcJnc|VfhO9!3St>E=rbEvpsj6$&!&X%?ORM#{t%P#arEB{cOWc+C=_U{nf*9wH- zAVzx?|NT!0zL^c_{Sjaz7gIc;auS9x*`F;PkDM(_KVbt)TOIYP-?Uh*i7e=ZAjI zDNNZ#3fHsuDk2eC=eI#9thyvI1V~%khhlh!(cG=uru`_@x1Qf`1>7p$i_K}hhdDGg zo9}+zHi6d=lm)vTl~QR%RJ;0!9ZDogtAl8ibOvqz_7*0TIpv1lFB#KlF0R{FqU%q? zW&yfvjVfhHl&3ubPI_l?k8;D=$4`HfsE}FT@bf|LiG5+>Hz}R@b=GP_O$zI|t|D3` zW&jSIlIYt5^(;x|eI{u7=bK zG#u9`W!ebZAX}Y&UC5<2Z5E?F@@7%;;G!hQkFleS>O;N!&E!9Z!H@N_T@1qUMbWDb z>$YZNMGb$3t+1v~*wTY=I#&;QzIgxU@(7k+Pg4X}>}RQZl`MR9fw}V~x|xmRR}^`d z{leW4*W_;tKaBq=`z4Mh1%_{}Jp&>SBs*k=zW6kA_Z`1a6~bM9Oni|+OHY6Bt+3s{ zqfp$wCPa=wrK53k3vbuJzV+)>Xb~_~8@};mU{LggowM4a+M{r$KRmnNpT%;}KzlJk z5&iL4uRhoWwpD+@Hr7lrBjW$@_n@Nx`_Ev}D0oPK_lH8>p$fSXcjEQsqgBb;$+x;K z%e^)Uk4?`ne9B#VV_{T8{G^-mEbFwWncT||caQ#KmpSqe{RPtmV4VLRuh5?YkHH0Y zun%gm5!|&N;s!Pu%R*R?Co517T~!;@swAbL7WDS`-@hJMx&ku&$A`JN6S z=fOS%s?j8my=~UYXuE}aIy|DE(9nvn`nJ3;{oQf)n{q3=-y4opV%EDU_iNko_DRbx zncqy&v_V~Wa+u2ROFyU&P3^0pR`m}+g=D9}66R*oPXG2<6%<>vZj9#7`SNh4w6}e) z>}BoO7352m1G@~E+f{$?fTDxl0G8x33CU*xL;W-|8NSdU?L%1quuZ5tMVZRy(dudkL zpMy8dGC-qVvZ*A|!A#~YD`n33_FphYs#`c&0FOza8dfPQ#WZ{=a7-LYJU=8?#FWz( zZ_e3~#bPD&2Pqe!N)_Uv#F9m}z>{U#1#D{~uP!_G69ZTw*B$E__m1Q@X{V?3uey({ z#0Mt~8KdDvg;b$%O8H!?M0z#WYck71<#oMMN_TK|zv#`=y4FWHDoTV5V1a<2h1R?9 z9aNZ@&w@oTM`=c*Xq~;X(MHe};@SZugd`qm4JRgN57^Btu6pl<7xG&L`7>QI!-$W3 zsQ7qsp2NhZ8MqVNGXSnZW|abG84%-3KxfhABbtZlLi5zqp>=8z#WpYJ8g4ZJ`b%+ zemOFvI%NB{U3*h!Yd*FA@VKP^_~AxI@5`dnWm#wj#7#J)yt8c%s&Mh7?9_U?4w#K) z+_-c9!jfl7w-vh9JDl9HyLxQ<*~45T_nT6T#}4J^&)K6o!7Aawc&us$x|t_aUjR(J z1+3Sj`y3IV_K_KUsgp$8S_*p_su3J%mh|a}1Cyb-u3+rIhtDt=Pd4J;tQ0NjEgGMQ zyaBczmkVk*ID&v})#OC9nq!_;ofrnj)9cwYSax;02%A8|cj?H36X=uZe+xk{YXO|# zw(NRAz&>!a^Z7R0>Y>&=;(RIWEnz`=%JG6!n2OT&h01O11MHmNWCI>B3dt%OAkO>; ztNAyN;%x*zjt=sTcq?dlxI_o&iCFQhK^23-O688;MO>I5Pv)}R(;1T*IYUDxAsm87 zIRta<8<(kD|CU{2JrVdxFg>?0gkG{(M-0M~uzcMyL{+$#xPMZw?2DIVA?i{=xhDv! z{oe6^^7Pg#FdfavEx#|cT=<@W2r;#Kc~`E5Nj zyZ*iJYc8wr9VR772EtAebJi;?pGOZF!rbE#PjrA_-VU8x9-N|Z`e8H<1Xf;1PSCn` z0-^ZQX4oBNl=Peime)$Y{$;$_&7xz(f>V{QAD@T1dkksaGhsuNXqA#~25jGupY_$k zlKhW4^yMtLbcY#F%JDR1+|zaUobLge1RufT7Dfj`0{gch1R)?+UpC@72(J?BqLuuJ z#}EYZ+0WA$)rN-S#|Z&C5zfx*HgRFY_sW|2*_Z5g{Q;c?X8)3dv#AEU#dG*F>18O- z`h@j@%m)w0+iVmWKls&WN^3!d;qC#qek6H^(6m;Ex6i_IJ7q^@X*DLdZ4G`2a&u~L2>qdA#g(8z4m*fh z0tH#^Av7D922HA8Pe&(f-!XoZS?#N>=HipqnM{c*67zR=-5sj;$|xRnU)_A!FlhDe zf5PAX1%vxv{gWyV_RjcB>**5woUW0uN%cRalx(`1?!K2$N>5c;0Jzh?q8&pDSf^^{Pp-3i`fIueO@tt0$MrEz}aIdw+1^WGs!nR}rkCEcgYDdjNltJb>h&gYLCJ?-8F>zWJ7HrhWo@^a)Mf&EQ*kK5DCr@9X$#36+OM z9UfF2(l&h=zghaF7tpt3K8w(K09jYBY(yz#1_~hSo+o8B*G3%K#A(q*#Pc&)KDy3c z@16B`VHZ*xxuAXWMFC77?kz6?p90`@s)%Y|wxlkz^g*(%Bq|wp`f7h24*qV<-=~YFIq$yv-#B+n-+I zn-(2eCku0r*h*K%Guwk$JrR*n+T=Ny9g7rfon(oq1t%@7WsMgK+pgQGA9^e|men)& zRxsrHIi+gFF+-q{6a$Vu3ynUE5d6cQjdP%Le-#giqZ-pn@hf+Dczw0)v;zE?6Yq2D zjXHne=s6!-_VHmXEH90Y`$OwU1znK%k7(RP=Vz*bETu7>0PhELf1TQ?t(k{kNvwqJ zBMWTWSAxVXn8G$WlTKxPd^af{3-f!wCIRY9Jb@5Sy2o`1gTp2@d;127b!Sa~rRXLn z2d>KA%*nsj5a?jWD#jFK01pPYhDSDgW+A))mmfSSAX)@S0}KQ~5`$BlcV35?f3o!U zq*y%QTDAZ6_}tjTC3aYDAcO2L(05N1Sx5gCh_(ecTAU50eL7X&eO|wrzkmKuk!rx1 zLt;EeV7TaDc1j3@;k!)Kjp_<1(af=FZbq0XRj#n@&xYZMojja>Wo>1*GDhG6iI4QAd zy)s$>X`6H}sOHWwz9Bgod>@g$s>qptF4l}`J!c+U0v86K@*7;Zl6rTAo?RghK?nL}27IzAR7OwZ8Ae#Q6NRWBs3*ZuUq;Ep zQyfo`3&G0|pkFC2cxhg#yyjfLyPWs^!G&DGRaTi3xS>;Te1FT6(5c&0KZ1&?W3$86 z8M~G=3WH)|V6IH)dj=WF6K~kd4vPP3r*pEBEC$wE!l?oh>&Q5&*wfMah>8Ihv6AJB zjhS=qUH64z((L12CY}-l^*J+4W$gsw7J4bll$H*RjwID)p)R0RMWJuWrt|6G(~>p) z565A~?+wIUS*#565;Ays2$X$F4EfW;D9zU4_-KcNgIz6`WxYiha_-|3c0-x$HcP2u zQ~L_hCevtqnafcqGlp=`*OKI)PSEWU{(QNo-PddIDOTB+8keq1Ta8Wx5bJr(R0LX2< z?+!At+sgClXoGjSqh#)x`}0$JL9g@JuXT=y+bG(}#2Ye-$-rx8|0q}Mqe*uVTNpCu zFj7>Xk81RmdaTY@pQq{V$G67=wytX*So-naTy5yfLyr})5!Vx8jjS?pe@J`(qh$qI zmw)@#uXXr4G@T8&uEJOPdT+mVYZJ``tY?qRdLNCpT<^(jb&!2%AgijY0yx1tr+t=RgC zp7iF)jy_v?r(0)$B4?zljYLY;9;5T0rXBzPGSb5FV{&nbhwVLTzVzV2a3_JZmLM_^ zW>x1C&xly`EAa5VSN1WMF=xZY6RfpyDamFH(i7=N9<0;kVWN*0*epPICy#lFrD zhSVcIE_c3JBs9ofHn%IhWl;7@!Q?QOR}YvNW?AUxx@xuvQUIfWJc1k@8ZJ8$8s+EW z>;2@t`8&~!nqkpVj+6scU_6OA0(hi7Pau|d+Ly8bSrL@{05{)qwU2Py)3ot=Z*deR z?3di*J&{Z1>Y?kwkGNTwBcE@BwF7KAKf!l_LfmbDzcxf%_IUD?8Ao9y4H_3(ZSGyTiL!U0|sRy??ogSrN-s1 zbaHlGA7D_-s=X%}Xok<;yh6D{d!i?>f0ZirmKsC)K5!=>ty`9;6cp@MWk22{8R8za zSCKDql>CxMqE<<J&^lr5T`hh%wL6kH{v~9M2ePv&wC*l)5I*@r zQ>8`tQ20p(8-6u2p&sIn$OeLkstSN-{}*|09u8&u{|}Gs>x7UjqbQZFWDA)}S#qOf z$vPE6LW+zqm&hK9P=t!8#3XCN*i|YbdzcwZWSc?b%FJ9nr~C8$9LM+heeU10{PTVO zc>XXP9Ua$P*Lj`idwsoMFUE^v?`-vS&N19&sr7w+zFq>t-AZo`3&cMbbBsrc`2G$8 zrYaFjrtkwULu6+ye1?|&^Jh1?EGf8-%AWN0qDQoOO+P3=eI0oroxGjLU%KVdu1G=< zGBi<1c*b-?z9B|%PKi?B1b)={ZNHmd&(pRyW~ZLpe6A6^mE0*`H3JZC|1anMIZhH> zp=Jo&RH-q-F9jIWNNAQc*$>+e#0n&{4UNMBU%jW#RcD7;9I5w?+9PJTs46C&CLzH) zp|L{1gWSiDBm>A5Q&}LwAq|2c;$|fFnZ62nA_+%x4G@485x(|kgu(2P=wA;@4b3mV z_Nn7;OK4OxFWX&t&|?3i?UGb!EdKE!g|k%_7a(O4Rc9g@L|~?f1T--ZL{N_$#q{P% z{O$uU9+dy0=3Kx&xKMv^GQyxO4_!sePY&nnadA!7F-f<4S5>R2sv2vesT$cvxjz-8 zuzKtFAn;4*%k(Ln#IIRIjQ-i&J~gzsr${ z=UuZq`FEdMjYM9;NFalx>h35+D%>1)r!-(r(SK3B>p*iMZg*~RCFx=Rc^`Wpfl8%m zXMgDs9_bM~()Iru={E!uDcmo^?b!E?l<_#@8s`Szwmh-^pi(2Ad+y>c)?TV-c3#@) zp#D*_jpI=o_f0tvm?_}<7>;lTZMl7;D7q9c*girie>&FX9_F_v-G~`la4{uL@J=ij zL1IH3#7bp^_mH?Y5mwcR4EJsn94xI@qcH|;>j$e;-q}}0wW|k}jOy`t2sVuS+~snq z=B(B!5M?`qa@7YfmTVsr!?L6S7cfutN?0-!r}6{*+y}p4OA%z?YuWk6TdIrXYda1! zJ9;GFvTqdu09LGU_+fS$L7C-9pSefLN5j1Bb+I*NW1XI!SL$3oUdniHC+&&4b~6q2 z)0YXjG!AP3YUf6pMpHhw_svJUCTza0$;immM#qVENz{AKzcuF-D)bN&4z2=Bhf*X& z&;$r^;G*Qk8o!1sqmvB%CYf`KYxscNfVGw9}>2dAMPO9}g|zHZY{?d#n;LwR!W#M8f|E-GF;9v!I} zc<=9C#T5vYhY|szj{FiQhpny`r0PAPdnIc_+kB)X8P?aik=zK#kkQwvbm2ajv6KF& zUw1(06)(dPcN+|$6Jdk-_WU=X+52L$J2qbkk%E2gdl31bHS)6cB)V)xY5&X^XKx{5AbReYNIBI~WmVXF(TJlQ0;Nh(o+ENa9g+lP5&qY{2%}G9Y#|f8Y4h zikXDsiIZMl7bWb>l{i4MOS@^k0MH>obsQ57CWR_70zXFvZ&&8-|6$zF2r{mS%q4Ty zH`5bc&po(e6@3)$OkI49^}@nO+&i8ApgJ{#Kq*m_Aci+Wb4^U_t}fAyn|X`$)+DSv zH9OJ!VEA5Qxk(54S;)SfacjUPGzYNuZW^AR94e8u?K_w@V+-Q}9GFf|#KB<6?O_u` z^h>Da11TOKg+|LoZCIS4ZV$%3ee}DiU%Cx9&)>rR{rKQwJ3fxJEsXIU#95Cf${kV= zOV+HkO}p#MnhYC!3FaG{hg z*o>1WsD_&i)GNR8Mnr09EAUO>;~Sj*HBu8kQP;&(DEU`>-^O#PPjf{z$qW8RMAE+j zibPynfUk%I@f^`@7adeNUCB8Zez>oty5yCy`n}C~WT>K(pZBx-u1|0De%ae2Vx$AS zbEN=>iTNDIk~d{4vrGm+MbVmvW!6`(Sb8~6usZpI;l>CXR;S2YK2 z6O@Z-o&gK*CYPi0`4N5mXuF$Kzaf^=E$=#;3D9F}ZpJ?~F0c#LA1YokYV&=b6?*#B zXp>R_2lLjyK!Tg?wg0&vH$`S3umL9<(cts2k^B&#f?rQsWN8#%&f3-cQM)E{>F0`7 zaaWXt&-T63@h*FRo(u;?1W_kK8+Oe$IF+TO9e|iVMt@CINrR>6fG47|rUfK?@;rm8 z;ieQw)LE@RnO(4yVtXVt_(IPM)C;byd}Y-D6hutG`arN=I>VHmh8*&h=b&e`t)deu zc|~!}#oje__w9Q{)1@=Vl~Zr7d5&cm%I-ih{S)B=1`ceYSjdAk$~kG04#NIsta$zA zWPMCp%fy+l#Gs+2LQW&6u0XM!cNs3z?>OC_F+-u|vj`8=tW~+l1Kn}ciEF(EXPTy3 zRmAp&&3DBd%SwNLOa7P`t?2j@W6e8Lw~k_tf5qjhfV|I<(5Bu5we$`g*89<-U-Jj0 zI5^82Ss(F~b(UtIMYR_GS-oQ>x^#5+bnFvP&Cda$g2`RG^rtlM0@UQd(?mf(X+w^0 zfF2u20j47&F6?9iceu@#7O<)q%SwT^oJ#D!RY^)Z zUEk+_TlLt=xP{|IpVM1Uy^4v-^sD_oOb^)H6|XU`mt(8uUD{%(3*HiWQu zz`u0tUNnc2rfcQ3or?E^Julon#+8U#2cvWOtfw^$U(m60rsT7=ogI2IX0Vm(i)U$r z3}IL_bTS}5n~_Tf{$uuFp^>|Z_0vwF108~9Kb}6_XLwa*5(g*tF+i>4X~8Rqva!}~W_peg<4Iu*m#adDsx6kJc#Ux zuO9@{sJpoJr zDwmN$>_rT$SiC?1R&JJFT}s8>#t2_w9ij?f?!V%%E&WU7v(@x@;o45|F-w)>uZGkW zRY({8zqEIDLNq5!cZ5F!vv|x4vuNq#9!fVla*!*|O#gkIMR>ldtK7l+{_{0oy8Pza zQIM)Odxp@BSvhJb479M=aY(u;E&;08^A>!sv-JqN`7=dM`#k+<|9SntvRCmBE2rwd z8&(`>xb9bT13M7)ww*w8stoXxIqrTSgGckhh=Z)Vth@Yuj@{ugV37el_P#FCLt^av z3;^tkMB?StgmCR*e0S^Mh!xS@=LKkMp#4I8g2lRoKUoR9(Lb>Be$+9p^NA>g%l97? z%Z348c08hU z86$!ygr8z!lC*A{kShAtl0N_Ccqr`M-1SQ}D3YSOCcyFF?Qopl5f7 zoM026Fa(sIcj2_jh3uP`o#Yrf0a|ThxuP9gWr z`J5`s5#@7m^P~zS)Liym##a!(c&VYjPC@L`>*K_oC&^Nym6E5e%!m&lstR@}4oOTY z2h(}6puYNkjRX5n?6gd5m=f@&c^6>Tr)$|@ps2mI9zgTCEGKN6B2c4 z3gPRsd4b!D&hh-MqG_Bj98Q1YL|KIkfE|=fa!&;!#7#!~-cQ$a|UkEJ0 zhsLmT{H*W)R;xbk`sRS9lwvyWDlCYIoMhh#5257vnKx>C*U7l-4R~`oGwXos$%UVa z=h2h6PM~DKP6MWh7KmH8@OVy2%6+mXX88^u?tN9%jCo{db$dBCadS}(U4VXPZ$3*DGpYTf z1@H{bj_>I$3)k5gD@F3dW}H7(Ysf8>SC5Bme{x~^HE*EHEnKJF&0;WF7Z84qeee7r z1r7w-Wi8c>d;5xdtDK!3R|CQVG`0MR;#+d6-}#06AHP;8@B4AMMm7&XAvQ6z{|I~d zzk@@}Xdq}{z3%6P>w|ZB!3)TAy4sWE=9#zIZ?WWuI8KOM=p-3e%(_IANKNW%z24CA zFp%*sNw7EG-r1GE!>G~+y&x(t&d1p z33C%I_30<(O?%ct^e&LO&aScy($Cf~mCmvYn8%illViIV3SIfPERA1K_-%3&FbSWB zc0<)9avXMzpU%r(pTr`Ek78_~=1lBL;)E{c_yY!rJdkt&4<086ybnhj(^z>vrv&|R@( z2N7B@GzUy1mS&U0zT8DYr#z+eJfr+F*SO+;#b1m0EV<6C++~+R5nlCiBz{jL@NZGI z^Ele6eAw@Z59HltV9=eE;N@TUEn9k!gNt)IiL}shF8RIXr0Oz`uAIqMu=>x$|Y9i5_ST93F>RxvQkg{=rtJ#_)QFSh$@& z3cgln3rx&C$nUm(XvN+WQK!M9+vs)Lm~{e~Trz{?1Cc*i|2E1`%4h7-Kpv@;yhTR9 zvjLyiquhpBp8%#Co+dninuUU~Lr<3}&>cl~%9Gy3D-LH&=v>z#-Z9TLHcv|n|JL0j zS$?CPyTTHedV!bcwEHBItAK=k|czI{M+ zxMv~j1g$8S=&hfT2XuNEb0MB#K6|qTpFi?+mBV`~{Mc3bud2|$sapPreslgF1zQos zSySamH^kr>Agx6_BZv?F8Uq2$0$5l{G^}bGTQ31Ug*!03a9`7BL&>y><9$BsIu-YQ z?D~0Vl!D{0MD(K$eTAwIZk~CW#ZUBN^zaQ{v-dQi!4jpLnuRLIS%Cj6ne3!`9 zA?AWINhXeF-Jp!{Co`q+UKI6=SFXOFpJ~<9tgk*lb3N1`;9&uvFAfn9vEtp+7!QzN zx4TC*5Gw$Vs9~q*laS2MxUpOUF9A#fT}q8_)Fkwc9|Smr?j!=H*YFTE`IMi%irS5j zp+q&$+)rsw@Ua7@7e=i(IsoHs0_n9WE8n^~fGNL*W$PWo6_RK-Yk*cw6P9tRa+z%~ zFR@m$payJZ031g)U34UqWLU;<)ni{bIa+M7NCiVq;3M9@tW0gn@q+70^2@x7FZ}rx z&IiBWC_n_kg7Pebh>!aQTmT~TcsB`pKQX%Ahr-?$Zg4ZZ5FJ=NHaDlWF1k%Lc}}w{ zP~H6Fn_A{Gqx0x#5mGr3*6Bk|r-H$eW{`Vq1l1EifU;HeE`sWkOruV?!DwD!ksB04 zSVLMS-pI!>xseY>yzog%*P^j)uc7#PKgIfn(H~>)-mh9Ha#KPq6z>)o90MSI1^EiQ z;N{;OYe(Y4~&^e@`c$M=iiY$I>fkXrS7Fesm ziJb)bY48KIbPV12Bwa55mOoEPk4QD`FV<90p4J6)c`mAgC$vm@SB6}#T~>>yFinsZ_AS!T$4Ma{aJvW{;A61 zS&CWpmq>r_k;5+Gg>-J}L1$&nw;cGsP1&YXDKAoh z=@cn|XhUQw?zbftM7~V4F}Rj}KRd&L3WB-`MA*Ea6p%V<+=h`dZ&}M6ePTrsu@pGxyEM*SiV**apPVK^gk#i&ZY^5#t<~86%32hIuW5{dL?FS zWvJl=dO^!|yqiI#pN*P|)m!J`^StWi(mYL5R|T&g%Jbcjq@~9C)k{z;HB2j3jXDcQ zPI~e_7|riW-lA?Ttp|u)wf+sJKom&iSD2Oi-GIjVBN5OzF9*As(n63^KVqN-4NzSx zNFzN4qO;4a9UMf$xBTjmRHNNsQl|9DtUk6)*nYCjA)D~dOKVWFX8o+;;7<9S(fs$i z6cn$;j$E%iNd1GlqycNTwvF?_zbUT1RH+WM$WJ$PuSW0s>B^p0UWv$@`|kn0@iXPR zdZ*7t$0$Wwaic=JS@IzKR(?qHMcUqix_+G#%bGavj0ge~#Kj)~tMWYAlnuY$QH`Jh z(bf5g1Gy%W`POyiAwGN~(KkQ7kw=5>V+RkU2W$gheiClYd2`5?_{kXJj&=+S@5F=BBFVoH1_Ni+mI}`{R3)8<8*86Y0W#UOn5!aGHEa4KKR|~l&L08r#u7p z{zUkh9m$oY7roCYAjTpMeC$4e!G5b~56tgk2{+|@!RuZvm+#<{X zzk7e5VdMGlUry~keE{|0hc*QQ0@bh<+KK%V3tA9zfK~iD{p<-El1k@1TT2&a;!95(=kHk>hLx(X!G^%n6iAX9FC*cQ7f=MsYD2ce z6iTiIILR2{pYlR|%W3nrRXqz{NvG?QHCy!Z&Xfxtuv0xYQh1jig_+g=-$i+NP=G(n zQ2q+q8~Np_DFk6S+NSX<2mz&?TB0_3IMoViE+>Nn_MnAIv<_yv>F&L7?@=V0QvL5} zFLg88LjzVTdm`(YINcXX@@gfqB*%dt1o(F@EICpPYE}pagz6P-jXKrz)MP`4%XKH9 z;n#*cBAzAIox1i$cu~bHIuB(hXCaFsWP)gqgH;V;lo`@WrZpNG*6YKry*oqv;DnTr z$n`jyzh^f~qIl9YmY@VwuruAz^h*z)6~#PnFL7>Y&-h$;%|g|3`yGEriJ7~yQwJ2E zxDiAuH{G<+{+#D5fUh8-ZyR(zoSw_dss8cE!(9)Mp)J-Gqb@yGHexhf3&P{N-+UDe;htM~b)-`|5K z+y3jE|4;oU0N_RQe10XA5)S_24(EId=h*g!@209mnA&|?W15F&G`_7#8`~KweRW~$ zDcI)5)DLM?Y7+@|ln0sEV+MHKTcFQGw_{6!E0M7@tJFKVs?y>XtQ_kg&8lj05!+mj z^KmN5eLURt{POFnz83n&>T*THd+2@9np zUnMHBb{ANy0DJeIF2&}pBC>=*w3SGlJ4*(lY8sOl!CjWh?`P-xYaKD_q;@AsR59g5 ze*U@;1ZGfks~=~ky>C)-P?P1>dB^1_XE%nrgloq9CsFLUO|Z||YjLj_k9%p{Lc?gTC(DZVreA`I3$$}{{b zI|q!`q$Xe_8kNG2&@rtfVM|Jap|V#1)t7&*!TR*Ow4Wbm>+LIcy+3b8Zavj(mv_=u z-3F(zP6fe=Ke&gr{1Nxg5!_-qZIqG=bU1x6TZZ9Xqiu7APF+*SH}0=*Bv*WY` zxmsA)N39_9;R8JdW(xSFNJm@Exa59@WYQ-T3xi8=+hrnDhg3>i9h2$>!A`2fni>;_ zjOT9$4Xj@4I7rlkJ_B|M|6uYCG}_nc=Y8kJz|5$&y^~~F zTf<5di&hI#h;;Gf`lrC|)-e}ov4OxK^iR;t(a9k|&gDmx5$CyCdwn1!#StI8XlneX zUi`!UXJh@}GGA*ZSGeqQ;U2nOQm0v9boMImsxzqmDYoI(2vJI+<9R$yXT7br(=cG~u`?5Jv3n?8TV zy2`qmZtqhp=J7ykeg2g`t} ztOTw%jwPjt@Z;#IK*?w8buzjY$2%(u1L~12SAwvMY>ksX>SY=+MRy0pbG9>hYj>G` z-@5n4Q8SyNv#<(a*2`tTAkB_I-e{I?HVJg6dU%)PSkn1SV|a8@YT*oBs&#xHea|zZ zj}bS0n`HJj-IU#a`qGazKZqTc`6$)JtrVj)DSeoIhxic#YY`cO@H3_ap<`U{o6%k% zxzJn#ZLJ!r64o{QMh%Ow;oBMC;zk|&rp2AJ+m6<5VumV%9DqL1lm#N_ZzyFb{F8~SL76rzp;oG+TmAT6KfNna4TK0Lh|q>C67l!kZTnSSl8w@h-S7t zq6^~PsORA)>76l%D1I5tiNG;|lEh~}<;8l+em$q2jN0q859t=)?kOy7;358L}m z6N$)Hyo2qx-K)!0YVy55XI!6ze^82%&JjRKU$jD@9Oy~38icQg>A*S%Cq0Gz2N^fv z)-V)&3N1E6`Vik4RYOsLtmC8$d>2W z^s6cROyw&1o;y=`;7yugJC}-(&e(@{^Z*3#aGiy|Rw6wZI6xcP>|2k1fC2*U@FGHV z91yt4^TpB10Y#o^YJ80{BW?(c1?=^aJ6NNCKn(3sfUg!oVJqG3uK_mi=#plGNFxBLq-{{ZG5Q#<2k+ z1Rh(Y*GQjk+CGsqA@a+5+vSn0Xw^!y;CVmwpE!L3-+@{)C+{xLyOnsMZ83f?&2}Hd z^kfjWft7iTqgj%YF>ijfStmkbM!R7&4vu&_pKa@g>078j>8_LW()CMv;qtDLPe}^5 z)p;K@@wmr~RV5C>90?(lh@$AeUrx6Bt#0y`%c zX+l7B6y=8u=N*7?WuxQlxWDQ{*W``|buts1PqB6Dm^>tfI^k z?E>TW68RKcCtQ-GAG$r;&<47IPy4{}wr?y>;3qy)Y)JL?9mf99$7t$OB3pp{255c< z0B-ER`h)jXPQhm$vQ{68d{mfNuz=KN`=d#CL;e52L>QB7U1!*Vt>Q^lbtne(Oj*#ysoSS^dg1dg} zV>3#!iG-~P3dNC#s@kmAttJ2?Qbg#JH|mA1U}CXC3oN^mAy{&>xd+Y-N^-1^SNis> zfmH4LP~bvgpb%eD{k`p)R|GuLL#UlB)l^ZaCk*(ireO$v$({w-Qv-?o5G#xIL63w` zy4#^xz)T-*N*8ZE1G{PPk2}L&*7>#|+o&yD_K)uJ70`;}O$k?hDQ(>Gaa!=?3<2a- z|FGdWn4Zj&K)-BzFdEh}ZpMnu&>6K8TMbX2ZLK55>neQH010!I|3YH@laSEl03q+* z=o##%PwfvuQ^OGS0p1v%1XRR}!0dB^0wlF$<1D-hBjmaR4=b~F3{tSe$b@m4wwtL43Pt&V*IW&uZFq%M=T{D%DLhnu$pY)2X zB8gwluDM;W%aikR?qzH6M;0?{$7~BK^bdjvqHTRfqNLP?0Fi zw_r#YM+*k|5ovO{9WWf%!cWM!SR)OP31FqXt8ZMjpjhttajv#Jdtj$*!gV3hiq=Q5 zCW^jWu8wBhM-gs)m||MMUebq+lftysYG@y%;4(f7MZ z)v53vs2$oG9t^Bw2n$sG5`XdZ*IQDFoIX3sN^X8F^b5JJ{m8~*r>(%_ii3Kmd5@^T zY%sBTH^0g&{#Vx#qY1_pcuuaJH5VX0hL^Fay5%^uSJP5eMb?{tdMv}%BMr5OR1T(d zx|EpLKwjKqaW2?e67O#F_0`nd8)jm@NsbqePaAK2^2j67Oq#s*7)>eZ$d|7V zpi8xH6vu0KOW{*(2dYZm1`wCkwfWtAUrb$GR;*rfe2`|{K!}B!U*am4+t30~vpWF; z8+EtW^@6!#iLw$;s<5Wxr-6<8H%9ChzsqE-R!!z@^9*i{biH0R<~&W5g_>otmzPoa zF8dx(Zt~Jd4#{#l^1b?6cM<*&7^|M~9(sZI-qhI>F1*t@`F)rP3`qTLXkUtHyIeJcV>#7+&uCFk>ZGo%}q$ z{lOuVG7h%>e~0CPAQ4=rnq6X~xJk$tRl*8MZ_Cph4CbH&?3kC|-n&bRXM511P6vWl zD}9=BDk-=mHf5xnjUW_aPo_;bj8010^}w&c=3I0 zJCqZ&y91+XUZ!3gW20qjqBs4WBI-JPhEyqlQlXMyz39oo zJJ?I9uws5kfE0{&YoK)}EG$-z%0!&{6jNKBvTR#XncsC$q|@6g!?;XNkjLf~C%S4g z5>OvpcsPk2jYjw_=)RQV)=>jj(~ccuRWSp(UWQbTn*%qKOXpk42xkx__Ui6!sUNZbQ0*XHB9VK5=rE~+IGj3(Zbv8Ni`8>dF>!SwFP>_|zVb6P zzhLz6rQ7$2r!ukk)GnP&e{Xx*u!F-9%#Hglpx9K<^yK{g40wW1Sf2p-QNje!*SyXP z3MM$NIU|v(LBGlzpZW*fYwNoAEDWwRha7C(kvz4?Q9XIxi@wxH%}RKsUXX_o^}YL9 zFfO?lvGX{8jnAM~A(8zaT(bsv&IdR-BP=pvgoaCK%ara!Kp*S2+A@I1W*BM|xm+F` ziRf==|JK$WAaJPAD!ptNrE>p#yZPyf^lFG^Zaq@8v9$Lr<1!2r58Xbi9I1V-N9?NR zXB3!dyvuw2D5eTvssXHtIz7nR@sJkR$=j*lMmPXxsOLY%XPry(2+1zJpAfz~Vakp> zyuBvDHA`r|{@~8$^w*^8p6NmGj%XhXyH_4(sjVHZ;Nu@;Sn8*Ez`%o%B*?bI+2+K2jxDpQZ1vl2)0j zkyl}~<>_~ReD0xGkJ-OP4F89E+W)8TBF=O&W;SCBFpbHL3>fLtK$H-{+mwyD9j;sA z;v;!uqw&G|V46(m4&{s~$J5~pF;Tag6g&pka&ayBi&e!gKG(u!frfL3EuFmSw_z%>s5S;U@Z4Fo_SYajfoHy7+Z%@OAyI zrDtaD50!jeAbeQeh5E*@`9|HYrHe; zn8Is_GG9Ywvv-5L8mJrvTc|i6t+8X=vofC&eCObo)A3V5{Z$u#$~^HD+o^DkvkU-i zr@*|hB%8$nec1R4Pxs&BwkL?1weItFx;b~l##2Jhd#UF&M~ zi|3l}TV;1T*led6bACU~`-Mc`PFxHH;Z`HayC{c2F-OT^-jpSC4>*Q;dNE5PjI!RA zjHQ}H7dpZM*inss{6?pT|5*G>aW;Hi%bvL7sA0iN@9E(>(kiUeh4? zln^Vr8IF~UBHMDhjGW&WpHaP4C&_XD-Zvikr;&!vlRyd**gjvI6~`e8 zZbb=Pb-Sa7Tzm#??oEHOFr)OGk9FaUeSdg6HmWWrrXwBFF8y}M+11(XtEJF$8?#(1 zX-8{?C_aT1K4J+(zC9A$0W2T)Eq0#$__%LuQpb(6ddY(KPhP2z4LZ$ZKFf`Qe6&C% zFdQroF9(R<>_Bt`DxnJ(+$Yie-Z%6+lMEe8mE+bPYP=b^8D1(>QMvM1;r*85O?wr0 z$C^oRGA`Y5V1PDQ9!>zFJpyMvO=ZmjLA&;qX<*5DSfQENai0s7@GbE#voVQX4NJD09>$=v8!Dk_tmGO-VEou*js=8OG3*88bMo-=nO&Nm#>WA_rLR1jg$K7UY*emlWE9X0M#P?6-8P3hI_+9D@- z`skABnCntfdWWm2jmpNaf59~Cvluk?i;L_8k~=9rUvf`x;hQdk%+27K#hS+CZWx5?%di-^ZwKppTB$rvJ!S;KlEn_A`Fj4Z%ZLrffziXFAF z+~EpY^aoj7sz}*R!55@SU-XJ-zw_~9)1C8C`VOlHaj!_UOVzo7SO7acSC=yGRb4|9 zDNOtL{I9@6F2jpLV;ly~s9((>`~BCAQf{b(E8QMtRIr$?}EjnPxXAOgzM}!-{ zU99X^tt?er2yi=WomIEb$-&N3b^DfDzgoR7ra$6X5+tkIUf`f#LY0Lf_N&hCC$@!~ zA22^%ur0!7`s-^b=3d6FS>r8w+EWl`(0np((&4tBpV~~_+uDIwy?ZYlUoqXo$oYOY0QjzZTwmJ(MV`qCbaS6IJ)+>GUZGgD@EzqAR4g&lv7R(C}Q2$^|ISs4*q)yTQy<(0L% zG*|OzGMgm8sJpby|A{M#3EKU0iXK zytK1Fh1~r5m?9@9wxpjm2Hxwvo59WOQi7H%J1^i3@wVTSLsyK>UB};6U&@K+nk?fW z&WZhFYySU=dln1o_v9&!O>}e{pbgIIz;Ln-!48k%2-dMO?=EVD>A3;2@4LXutKUaW zfj4^}l2sGprPjoUI(!kU1^sK`@vMI@eAy&|cIzwKERLnIwCjCCGkc-f%%0AJ#})7O z*?voU?jLhsUyN5l{|8_w2TZ^e2%vAP)4;pckDF%8BnG3X$*N(UD149 z`_&T+=QU}C8#YG`9It=z9h;}%V4c)|?Yg)>C=O~}P4>7+`L}8Q{o5|4mQ3jDpfB0m z^F9&cBM*+*q8wH^H|Ipv*acOfQMmDIBC--4hse{e4EnL}75~h(%^Iv`Omc+JWqR1( zoeXS=6g=-Cls~sYip;gJto;g@q71r1OZ^u;yuG444t@}Pu`}cT$}2vF==ZxKv3L?W z4QTvkb!v?b9Cr%9NOlCb1Fg}s zZD+dzw62cf1I)ZVg7#N%1XN}mk>T1J*prBwtHi>F5aZ38fmbt)eTc-wxmNun?Qgto zS_Va9x$k20Ze&W!ybgRs9-JQpOk0Rx`b)wGEX{N|mQ<-6#Znf^+Xa6Xc_<&}^TK>) zboR~o(1)-Yt8LMHQ}l9gypVZ7RJcS+b7KDlH@yIdoZjrS;~;mv>GJv%OSO6pMjPJI zsSnvVU&$tSepr1&{G4)5q$tt|RBYngA>Z!b)3jpHSo{F}-bHf7k`pjAm+q~EiTu}6 z?Wyr3QNu%C=mbQ-`Mc}S&xM4U38xspqB`sSI;s~V?rl>jmu_A11>z1e+N?oE&hJOl7_3E;H_2$ zJMC-S&A(|G=#)ojQhR%;bXLC)=2MCp*>jTH*=I^T^@sln@7#=xjQixM1ILIx8n8Ce zsusABC;&tQA#G90XYJwQpyU5?VxNE$`vRPpqp8ha&JFm#N8`jFUWh;{2rN9qgJEfA z2xtaG(1=HAZpjo}+|B79F$n`}>!f$2x;M3H{7V+9&qd{W7LM`@yfpuy0G;S(odieE zwX!Q0kWvzvrvQ{gyE2)MZM(kzRLu6yJ;hgN$ry@<=lPpkn^O25qkF6Y(>_|3U7!Fj zQUc&}`e+c(ZpEwnu0`9w?yY>Q8gMBFIezhB(bckN(UG>Xqm^XY)UQAQT^td7F*yP6 zmH-;yhKjT<XrO zFr6(z%gV}vRq#(@w`nMqd7hTAy#~aC4r?>dfCsOi8{}j>gfXEEVCKUQhVkWGSXpO3 zmwQ*|->EmBh@$^P^u#bY3YL0q%ndyK79#e_KEMUyn;nrqtkg5`LvrOOcEtQy9QsZ? zATtzQuim0RxT1?|aNAB>dJJT@Cbk}9$aEhj+Ao++=}hv`!^Iv3-dPZhV%GU_{gCb9 z{S5s(D&Fiy{8v=`6Ub_ZfMDVW90NY>=)_b*Ub?Yy&qca&>=h}uivCJ(;nK?LqH|<# zc}XvW*4p!w*T)oY-O8UX+GI5)e^Aq>ko@wK)-R5``(mQRBp>Wt=^sJ9$EfuM3bct7 z?gRdR&IC|DZu$T42mJs`E}Eb@1Vrc4%qKWz>$=+o*-kUewh$g+jpIFw)VltFjKG<{ z%0D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�@W& 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
8xN2p7#5$ukW94JZ; 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 KF~Id_=6?W+s(#M^ From ef644b611288e7d0c415e28c3420424fe4a61261 Mon Sep 17 00:00:00 2001 From: wangyu <53896905+yenuo26@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:45:32 +0800 Subject: [PATCH 11/21] [CI/Build] Promote LingBot-Video dense smoke to ready/merge and wire L4 expansion (#7892) Signed-off-by: wangyu <410167048@qq.com> --- .../common/ci_source_file_dependencies.yml | 1 + .buildkite/cuda/test-merge.yml | 10 ++++++++++ .buildkite/cuda/test-nightly.yml | 2 +- .buildkite/cuda/test-ready.yml | 10 ++++++++++ .../e2e/online_serving/test_lingbot_video.py | 20 ++++++++++++++++--- .../test_lingbot_video_expansion.py | 6 +++--- 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/.buildkite/common/ci_source_file_dependencies.yml b/.buildkite/common/ci_source_file_dependencies.yml index 5dfbf8da4..99bbffa75 100644 --- a/.buildkite/common/ci_source_file_dependencies.yml +++ b/.buildkite/common/ci_source_file_dependencies.yml @@ -453,6 +453,7 @@ source_file_dependencies: - *lingbot_video - *lingbot_world - tests/e2e/online_serving/test_lingbot_video.py + - tests/e2e/online_serving/test_lingbot_video_expansion.py - tests/e2e/online_serving/test_lingbot_video_moe.py - tests/e2e/offline_inference/test_lingbot_world_v2.py diff --git a/.buildkite/cuda/test-merge.yml b/.buildkite/cuda/test-merge.yml index 87d4c5362..86e7a7a35 100644 --- a/.buildkite/cuda/test-merge.yml +++ b/.buildkite/cuda/test-merge.yml @@ -163,6 +163,16 @@ steps: " mirror_hardwares: h100_1 + - label: "Diffusion · LingBot-Video Test" + source_file_dependencies: diffusion_lingbot_function + commands: + - | + timeout 40m bash -c " + export VLLM_IMAGE_FETCH_TIMEOUT=60 + pytest -s -v tests/e2e/online_serving/test_lingbot_video.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' + " + mirror_hardwares: h100_1 + - label: "Diffusion · MiniMax-H3 L3 DLO DP2 and FastH3" source_file_dependencies: diffusion_minimax_h3_function # Keep four server lifecycles in one Buildkite job, but never in one diff --git a/.buildkite/cuda/test-nightly.yml b/.buildkite/cuda/test-nightly.yml index adc94f9e9..843b8d9a3 100644 --- a/.buildkite/cuda/test-nightly.yml +++ b/.buildkite/cuda/test-nightly.yml @@ -349,7 +349,7 @@ steps: commands: - >- pytest -s -v - tests/e2e/online_serving/test_lingbot_video.py + tests/e2e/online_serving/test_lingbot_video_expansion.py tests/e2e/online_serving/test_lingbot_video_moe.py tests/e2e/offline_inference/test_lingbot_world_v2.py -m "full_model and diffusion and H100 and B200 and cards_1" diff --git a/.buildkite/cuda/test-ready.yml b/.buildkite/cuda/test-ready.yml index 04e5d07b0..771554c0f 100644 --- a/.buildkite/cuda/test-ready.yml +++ b/.buildkite/cuda/test-ready.yml @@ -248,3 +248,13 @@ steps: pytest -s -v tests/e2e/online_serving/test_hunyuan_video_15_expansion.py -m 'core_model and diffusion' --run-level 'core_model' " mirror_hardwares: h100_1 + + - label: "Diffusion · LingBot-Video Test" + source_file_dependencies: diffusion_lingbot_function + commands: + - | + timeout 40m bash -c " + export VLLM_IMAGE_FETCH_TIMEOUT=60 + pytest -s -v tests/e2e/online_serving/test_lingbot_video.py -m 'core_model and cuda' --run-level 'core_model' + " + mirror_hardwares: h100_1 diff --git a/tests/e2e/online_serving/test_lingbot_video.py b/tests/e2e/online_serving/test_lingbot_video.py index e6f275bb9..0da9c4dab 100644 --- a/tests/e2e/online_serving/test_lingbot_video.py +++ b/tests/e2e/online_serving/test_lingbot_video.py @@ -1,7 +1,19 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project -"""Online serving smoke coverage for the dense LingBot-Video checkpoint.""" +""" +Online serving smoke for ``robbyant/lingbot-video-dense-1.3b``. + +``test_text_to_image_001`` and ``test_video_generation_modes_001`` are the +baseline T2I / T2V+TI2V cases (``core_model`` + ``advanced_model``) shared by +ready (L2) and merge (L3). Heavier MoE and World coverage stays on +``full_model`` in ``test_lingbot_video_moe.py`` / ``test_lingbot_world_v2.py``. + +From ``tests/``:: + + pytest -s -v e2e/online_serving/test_lingbot_video.py -m "core_model and diffusion" --run-level=core_model + pytest -s -v e2e/online_serving/test_lingbot_video.py -m "advanced_model and diffusion" --run-level=advanced_model +""" import json import os @@ -41,7 +53,8 @@ def _get_diffusion_feature_cases(model: str): ] -@pytest.mark.full_model +@pytest.mark.core_model +@pytest.mark.advanced_model @pytest.mark.diffusion @pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True) def test_text_to_image_001(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None: @@ -63,7 +76,8 @@ def test_text_to_image_001(omni_server: OmniServer, openai_client: OpenAIClientH ) -@pytest.mark.full_model +@pytest.mark.core_model +@pytest.mark.advanced_model @pytest.mark.diffusion @pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True) def test_video_generation_modes_001(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None: diff --git a/tests/e2e/online_serving/test_lingbot_video_expansion.py b/tests/e2e/online_serving/test_lingbot_video_expansion.py index 4a411ddcf..74c7c143b 100644 --- a/tests/e2e/online_serving/test_lingbot_video_expansion.py +++ b/tests/e2e/online_serving/test_lingbot_video_expansion.py @@ -4,9 +4,9 @@ """ L4 expansion coverage for ``robbyant/lingbot-video-dense-1.3b``. -This file remains dense-only. Basic T2I/T2V/TI2V serving for both dense and MoE -checkpoints is covered by ``test_lingbot_video.py`` and -``test_lingbot_video_moe.py``. Distributed feature rows belong in follow-up +This file remains dense-only. Baseline T2I/T2V/TI2V for the dense checkpoint +lives in ``test_lingbot_video.py`` (L2/L3). MoE smoke stays in +``test_lingbot_video_moe.py`` (L4). Distributed feature rows belong in follow-up PRs. """ From e01655f4a9051d5fc2becf94fd83ed90cbbd0ec0 Mon Sep 17 00:00:00 2001 From: akshatvishu Date: Mon, 21 Sep 2026 20:39:32 +0530 Subject: [PATCH 12/21] [BugFix] Fix diffusion TTS voice error responses (#7798) Signed-off-by: akshatvishu --- .../openai_api/test_serving_speech.py | 95 +++++++++++-------- vllm_omni/entrypoints/openai/api_server.py | 18 +--- vllm_omni/entrypoints/openai/errors.py | 6 +- 3 files changed, 60 insertions(+), 59 deletions(-) diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 146488aa2..9a2f9e1fc 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -25,6 +25,7 @@ from fastapi.responses import JSONResponse from fastapi.testclient import TestClient from pydantic import ValidationError from pytest_mock import MockerFixture +from vllm.entrypoints.serve import create_error_response from vllm.entrypoints.serve.engine.protocol import ErrorInfo, ErrorResponse from vllm_omni.config.stage_config import StagePipelineConfig @@ -33,7 +34,6 @@ from vllm_omni.diffusion.sched.request_scheduler import RequestScheduler from vllm_omni.diffusion.sched.step_scheduler import StepScheduler from vllm_omni.entrypoints.omni_base import OmniEngineDeadError from vllm_omni.entrypoints.openai import api_server as api_server_module -from vllm_omni.entrypoints.openai import errors as openai_errors from vllm_omni.entrypoints.openai import serving_speech as serving_speech_module from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin from vllm_omni.entrypoints.openai.protocol.audio import ( @@ -3875,6 +3875,7 @@ def _make_api_server_request(handler, *, method: str = "POST", path: str = "/v1/ app = FastAPI() app.state.openai_serving_speech = handler app.state.api_server_count = 1 + app.state.serving_tokenization = None scope = { "type": "http", "app": app, @@ -3889,23 +3890,6 @@ def _make_api_server_request(handler, *, method: str = "POST", path: str = "/v1/ return Request(scope) -def _patch_api_server_base(mocker: MockerFixture): - def _fake_create_error_response(message, err_type="BadRequestError", status_code=400, param=None): - return ErrorResponse( - error=ErrorInfo( - message=message, - type=err_type, - param=param, - code=getattr(status_code, "value", status_code), - ) - ) - - fake_base = mocker.MagicMock() - fake_base.create_error_response.side_effect = _fake_create_error_response - mocker.patch.object(openai_errors, "base", return_value=fake_base) - return fake_base - - def _assert_openai_error_response( response: JSONResponse, *, @@ -3921,8 +3905,7 @@ def _assert_openai_error_response( assert message in body["error"]["message"] -def test_api_server_list_voices_without_speech_handler_returns_404(mocker: MockerFixture): - _patch_api_server_base(mocker) +def test_api_server_list_voices_without_speech_handler_returns_404(): raw_request = _make_api_server_request(None, method="GET") response = asyncio.run(api_server_module.list_voices(raw_request)) @@ -3933,7 +3916,6 @@ def test_api_server_list_voices_without_speech_handler_returns_404(mocker: Mocke def test_api_server_upload_voice_value_error_returns_400(mocker: MockerFixture): - _patch_api_server_base(mocker) handler = mocker.MagicMock() handler.upload_voice = mocker.AsyncMock(side_effect=ValueError("Unsupported MIME type: audio/x-m4a")) raw_request = _make_api_server_request(handler) @@ -3951,8 +3933,7 @@ def test_api_server_upload_voice_value_error_returns_400(mocker: MockerFixture): _assert_openai_error_response(response, status_code=400, message="Unsupported MIME type") -def test_api_server_upload_voice_without_speech_handler_returns_404(mocker: MockerFixture): - _patch_api_server_base(mocker) +def test_api_server_upload_voice_without_speech_handler_returns_404(): raw_request = _make_api_server_request(None) response = asyncio.run( @@ -3968,8 +3949,50 @@ def test_api_server_upload_voice_without_speech_handler_returns_404(mocker: Mock ) +@pytest.mark.parametrize("has_speech_handler", [True, False]) +@pytest.mark.parametrize("method", ["GET", "POST", "DELETE"]) +def test_voice_routes_without_tokenization(mocker: MockerFixture, method: str, has_speech_handler: bool): + handler = mocker.MagicMock() if has_speech_handler else None + if handler is not None: + handler._get_available_voices.return_value = [] + handler.uploaded_speakers = {} + handler.delete_voice = mocker.AsyncMock(return_value=False) + app = _make_api_server_request(handler).app + app.add_api_route("/v1/audio/voices", api_server_module.list_voices, methods=["GET"]) + app.add_api_route("/v1/audio/voices", api_server_module.upload_voice, methods=["POST"]) + app.add_api_route("/v1/audio/voices/{name}", api_server_module.delete_voice, methods=["DELETE"]) + + with TestClient(app) as client: + if method == "POST": + response = client.post( + "/v1/audio/voices", + files={"consent": (None, "cons_test"), "name": (None, "probe")}, + ) + elif method == "DELETE": + response = client.delete("/v1/audio/voices/missing") + else: + response = client.get("/v1/audio/voices") + + if not has_speech_handler: + status_code, err_type, message = 404, "NotFoundError", "The model does not support Speech API" + elif method == "POST": + status_code, err_type, message = ( + 400, + "BadRequestError", + "Either 'audio_sample' or 'speaker_embedding' must be provided", + ) + elif method == "DELETE": + status_code, err_type, message = 404, "NotFoundError", "Voice 'missing' not found" + else: + assert response.status_code == 200 + assert response.json() == {"voices": [], "uploaded_voices": []} + return + + assert response.status_code == status_code + assert response.json() == {"error": {"message": message, "type": err_type, "param": None, "code": status_code}} + + def test_api_server_upload_voice_without_input_returns_400(mocker: MockerFixture): - _patch_api_server_base(mocker) raw_request = _make_api_server_request(mocker.MagicMock()) response = asyncio.run( @@ -3986,7 +4009,6 @@ def test_api_server_upload_voice_without_input_returns_400(mocker: MockerFixture def test_api_server_upload_voice_with_audio_and_embedding_returns_400(mocker: MockerFixture): - _patch_api_server_base(mocker) raw_request = _make_api_server_request(mocker.MagicMock()) response = asyncio.run( @@ -4003,7 +4025,6 @@ def test_api_server_upload_voice_with_audio_and_embedding_returns_400(mocker: Mo def test_api_server_upload_voice_exception_returns_500(mocker: MockerFixture): - _patch_api_server_base(mocker) handler = mocker.MagicMock() handler.upload_voice = mocker.AsyncMock(side_effect=RuntimeError("disk failed")) raw_request = _make_api_server_request(handler) @@ -4032,7 +4053,6 @@ def test_api_server_upload_voice_with_no_adapter( tmp_path: Path, ): monkeypatch.setenv("SPEAKER_SAMPLES_DIR", str(tmp_path)) - _patch_api_server_base(mocker) engine_client = mocker.MagicMock() engine_client.errored = False engine_client.stage_configs = [] @@ -4059,8 +4079,7 @@ def test_api_server_upload_voice_with_no_adapter( assert response.status_code == 200 -def test_api_server_delete_voice_without_speech_handler_returns_404(mocker: MockerFixture): - _patch_api_server_base(mocker) +def test_api_server_delete_voice_without_speech_handler_returns_404(): raw_request = _make_api_server_request(None, method="DELETE", path="/v1/audio/voices/probe") response = asyncio.run(api_server_module.delete_voice("probe", raw_request)) @@ -4071,7 +4090,6 @@ def test_api_server_delete_voice_without_speech_handler_returns_404(mocker: Mock def test_api_server_delete_voice_value_error_returns_400(mocker: MockerFixture): - _patch_api_server_base(mocker) handler = mocker.MagicMock() handler.delete_voice = mocker.AsyncMock(side_effect=ValueError("Invalid voice name")) raw_request = _make_api_server_request(handler, method="DELETE", path="/v1/audio/voices/probe") @@ -4082,7 +4100,6 @@ def test_api_server_delete_voice_value_error_returns_400(mocker: MockerFixture): def test_api_server_delete_voice_not_found_returns_404(mocker: MockerFixture): - _patch_api_server_base(mocker) handler = mocker.MagicMock() handler.delete_voice = mocker.AsyncMock(return_value=False) raw_request = _make_api_server_request(handler, method="DELETE", path="/v1/audio/voices/missing") @@ -4098,7 +4115,6 @@ def test_api_server_delete_voice_not_found_returns_404(mocker: MockerFixture): def test_api_server_delete_voice_exception_returns_500(mocker: MockerFixture): - _patch_api_server_base(mocker) handler = mocker.MagicMock() handler.delete_voice = mocker.AsyncMock(side_effect=RuntimeError("disk failed")) raw_request = _make_api_server_request(handler, method="DELETE", path="/v1/audio/voices/probe") @@ -4113,10 +4129,9 @@ def test_api_server_delete_voice_exception_returns_500(mocker: MockerFixture): ) -def test_api_server_create_speech_without_handler_returns_404(mocker: MockerFixture): - fake_base = _patch_api_server_base(mocker) +def test_api_server_create_speech_without_handler_returns_404(): raw_request = _make_api_server_request(None, path="/v1/audio/speech") - raw_request.app.state.serving_tokenization = fake_base + raw_request.app.state.serving_tokenization = SimpleNamespace(create_error_response=create_error_response) request = OpenAICreateSpeechRequest(input="Hello") response = asyncio.run(api_server_module.create_speech(request, raw_request)) @@ -4126,10 +4141,9 @@ def test_api_server_create_speech_without_handler_returns_404(mocker: MockerFixt ) -def test_api_server_create_speech_batch_without_handler_returns_404(mocker: MockerFixture): - fake_base = _patch_api_server_base(mocker) +def test_api_server_create_speech_batch_without_handler_returns_404(): raw_request = _make_api_server_request(None, path="/v1/audio/speech/batch") - raw_request.app.state.serving_tokenization = fake_base + raw_request.app.state.serving_tokenization = SimpleNamespace(create_error_response=create_error_response) request = BatchSpeechRequest(items=[SpeechBatchItem(input="hi")]) response = asyncio.run(api_server_module.create_speech_batch(request, raw_request)) @@ -4194,11 +4208,10 @@ def test_api_server_create_speech_batch_omits_null_fields(mocker: MockerFixture) assert errored["error"] == "Input text cannot be empty" -def test_api_server_create_audio_generate_without_handler_returns_404(mocker: MockerFixture): - fake_base = _patch_api_server_base(mocker) +def test_api_server_create_audio_generate_without_handler_returns_404(): raw_request = _make_api_server_request(None, path="/v1/audio/generate") raw_request.app.state.openai_serving_audio_generate = None - raw_request.app.state.serving_tokenization = fake_base + raw_request.app.state.serving_tokenization = SimpleNamespace(create_error_response=create_error_response) request = OpenAICreateAudioGenerateRequest(input="a bird singing") response = asyncio.run(api_server_module.create_audio_generate(request, raw_request)) diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 3bf3f3ff5..65db1e9cd 100644 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -1463,7 +1463,6 @@ async def list_voices(raw_request: Request): handler = Omnispeech(raw_request) if handler is None: return _create_speech_error_json_response( - raw_request, "The model does not support Speech API", err_type="NotFoundError", status_code=HTTPStatus.NOT_FOUND, @@ -1578,7 +1577,6 @@ async def upload_voice( handler = Omnispeech(raw_request) if handler is None: return _create_speech_error_json_response( - raw_request, "The model does not support Speech API", err_type="NotFoundError", status_code=HTTPStatus.NOT_FOUND, @@ -1586,9 +1584,7 @@ async def upload_voice( try: if speaker_embedding is not None and audio_sample is not None: - return _create_speech_error_json_response( - raw_request, "'audio_sample' and 'speaker_embedding' are mutually exclusive" - ) + return _create_speech_error_json_response("'audio_sample' and 'speaker_embedding' are mutually exclusive") if speaker_embedding is not None: result = await handler.upload_voice_embedding(speaker_embedding, consent, name) elif audio_sample is not None: @@ -1600,18 +1596,15 @@ async def upload_voice( speaker_description=speaker_description, ) else: - return _create_speech_error_json_response( - raw_request, "Either 'audio_sample' or 'speaker_embedding' must be provided" - ) + return _create_speech_error_json_response("Either 'audio_sample' or 'speaker_embedding' must be provided") return JSONResponse(content={"success": True, "voice": result}) except ValueError as e: - return _create_speech_error_json_response(raw_request, str(e)) + return _create_speech_error_json_response(str(e)) except Exception as e: logger.exception(f"Failed to upload voice: {e}") return _create_speech_error_json_response( - raw_request, f"Failed to upload voice: {str(e)}", err_type="InternalServerError", status_code=HTTPStatus.INTERNAL_SERVER_ERROR, @@ -1644,7 +1637,6 @@ async def delete_voice(name: str, raw_request: Request): handler = Omnispeech(raw_request) if handler is None: return _create_speech_error_json_response( - raw_request, "The model does not support Speech API", err_type="NotFoundError", status_code=HTTPStatus.NOT_FOUND, @@ -1655,7 +1647,6 @@ async def delete_voice(name: str, raw_request: Request): success = await handler.delete_voice(name) if not success: return _create_speech_error_json_response( - raw_request, f"Voice '{name}' not found", err_type="NotFoundError", status_code=HTTPStatus.NOT_FOUND, @@ -1664,11 +1655,10 @@ async def delete_voice(name: str, raw_request: Request): return JSONResponse(content={"success": True, "message": f"Voice '{name}' deleted successfully"}) except ValueError as e: - return _create_speech_error_json_response(raw_request, str(e)) + return _create_speech_error_json_response(str(e)) except Exception as e: logger.exception(f"Failed to delete voice '{name}': {e}") return _create_speech_error_json_response( - raw_request, f"Failed to delete voice: {str(e)}", err_type="InternalServerError", status_code=HTTPStatus.INTERNAL_SERVER_ERROR, diff --git a/vllm_omni/entrypoints/openai/errors.py b/vllm_omni/entrypoints/openai/errors.py index efa43bff1..612f620a4 100644 --- a/vllm_omni/entrypoints/openai/errors.py +++ b/vllm_omni/entrypoints/openai/errors.py @@ -9,10 +9,9 @@ shapes. Server-level exception handling belongs in from http import HTTPStatus -from fastapi import Request from fastapi.responses import JSONResponse +from vllm.entrypoints.serve import create_error_response from vllm.entrypoints.serve.engine.protocol import ErrorResponse -from vllm.entrypoints.serve.instrumentator.basic import base class InvalidInputReferenceError(ValueError): @@ -39,13 +38,12 @@ def _error_response_to_json_response( # TODO(#5227, P1.1): Relocate to audio/speech helpers when speech routes move out of api_server.py. def _create_speech_error_json_response( - raw_request: Request, message: str, *, err_type: str = "BadRequestError", status_code: HTTPStatus = HTTPStatus.BAD_REQUEST, ) -> JSONResponse: - err = base(raw_request).create_error_response( + err = create_error_response( message=message, err_type=err_type, status_code=status_code, From 2c2cffd2aacda119e1708a6302163d9b9684f071 Mon Sep 17 00:00:00 2001 From: 0z5a Date: Mon, 21 Sep 2026 23:26:14 +0800 Subject: [PATCH 13/21] [Bugfix] Preserve NextStep rectangular latent dimensions (#7853) Signed-off-by: 0z5a --- .../nextstep_1_1/test_nextstep_rectangular.py | 52 +++++++++++++++++++ .../nextstep_1_1/pipeline_nextstep_1_1.py | 4 +- 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 tests/diffusion/models/nextstep_1_1/test_nextstep_rectangular.py diff --git a/tests/diffusion/models/nextstep_1_1/test_nextstep_rectangular.py b/tests/diffusion/models/nextstep_1_1/test_nextstep_rectangular.py new file mode 100644 index 000000000..279feb445 --- /dev/null +++ b/tests/diffusion/models/nextstep_1_1/test_nextstep_rectangular.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from types import MethodType, SimpleNamespace +from unittest.mock import Mock + +import pytest +import torch + +from vllm_omni.diffusion.models.nextstep_1_1 import pipeline_nextstep_1_1 as pipeline_module +from vllm_omni.diffusion.models.nextstep_1_1.modeling_nextstep import NextStepModel +from vllm_omni.inputs.data import OmniDiffusionSamplingParams + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +@pytest.mark.parametrize("height,width", [(32, 32), (32, 48), (48, 32)]) +@pytest.mark.parametrize("batch", [1, 2]) +def test_forward_preserves_latent_grid(monkeypatch, height, width, batch): + pipeline = object.__new__(pipeline_module.NextStep11Pipeline) + torch.nn.Module.__init__(pipeline) + pipeline._NextStep11Pipeline__device = torch.device("cpu") + pipeline.config = SimpleNamespace(use_gen_pos_embed=False) + pipeline.down_factor = 16 + pipeline.scaling_factor = 2.0 + pipeline.shift_factor = 0.25 + grid = SimpleNamespace(config=SimpleNamespace(latent_channels=2, latent_patch_size=2)) + expected = torch.arange(batch * 2 * (height // 8) * (width // 8), dtype=torch.float32) + expected = expected.reshape(batch, 2, height // 8, width // 8) + tokens = NextStepModel.patchify(grid, expected) + pipeline.model = Mock( + return_value=SimpleNamespace(last_hidden_state=torch.zeros(batch, 1, 4), past_key_values=None) + ) + pipeline.model.forward_model.return_value = pipeline.model.return_value + pipeline.model.unpatchify = MethodType(NextStepModel.unpatchify, grid) + pipeline.tokenizer = Mock( + bos_token=None, + return_value=SimpleNamespace( + input_ids=torch.ones(batch, 2, dtype=torch.long), attention_mask=torch.ones(batch, 2, dtype=torch.long) + ), + ) + pipeline._check_input = Mock(return_value=(["teapot"], None)) + pipeline._build_captions = Mock(return_value=(["teapot"] * batch, None, 1, 1.0)) + pipeline._add_prefix_ids = lambda _hw, ids, mask: (ids, mask) + pipeline.decoding = Mock(return_value=tokens) + pipeline.vae = SimpleNamespace(dtype=torch.float32, decode=lambda latent: SimpleNamespace(sample=latent)) + monkeypatch.setattr(pipeline_module, "StaticCache", lambda **_kwargs: None) + params = OmniDiffusionSamplingParams( + height=height, width=width, guidance_scale=1.0, num_outputs_per_prompt=batch, seed=None + ) + output = pipeline.forward(SimpleNamespace(prompts=["teapot"], sampling_params=params)) + torch.testing.assert_close(output.output, expected / 2 + 0.25, rtol=0, atol=0) diff --git a/vllm_omni/diffusion/models/nextstep_1_1/pipeline_nextstep_1_1.py b/vllm_omni/diffusion/models/nextstep_1_1/pipeline_nextstep_1_1.py index 392e42868..9a3358d0d 100644 --- a/vllm_omni/diffusion/models/nextstep_1_1/pipeline_nextstep_1_1.py +++ b/vllm_omni/diffusion/models/nextstep_1_1/pipeline_nextstep_1_1.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project # Adapted from NextStep-1.1 (https://huggingface.co/stepfun-ai/NextStep-1.1) import os @@ -704,7 +704,7 @@ class NextStep11Pipeline(nn.Module, SupportsComponentDiscovery, DiffusionPipelin ) # Unpatchify - latents = self.model.unpatchify(tokens) + latents = self.model.unpatchify(tokens, h=height // self.down_factor, w=width // self.down_factor) latents = (latents / self.scaling_factor) + self.shift_factor # Decode latents From 279b1b96879469047cacbf3fc688eba02cb7a291 Mon Sep 17 00:00:00 2001 From: wangyu <53896905+yenuo26@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:55:00 +0800 Subject: [PATCH 14/21] [Bugfix] Isolate weekly CPU tests from inherited attention env and hf_api mocks (#7565) Signed-off-by: wangyu <410167048@qq.com> Signed-off-by: [Your Name] Signed-off-by: [Your Name] [Your Email] --- .../attention/test_attention_config.py | 15 +-- .../model_loader/test_hub_prefetch.py | 8 +- .../test_hunyuan_image3_it2i_multi_image.py | 4 +- ...py => test_hunyuan_image3_prompt_utils.py} | 2 +- .../models/magi2/test_pipeline_magi2.py | 2 +- .../test_minimax_h3_quantization_quality.py | 5 +- .../ltx/test_ltx25_official_similarity.py | 29 ------ .../offline_inference/test_hunyuanimage3.py | 2 +- tests/e2e/online_serving/test_ltx25.py | 11 --- .../test_stable_audio_online_expansion.py | 10 +- tests/engine/test_arg_utils.py | 3 +- .../test_async_omni_engine_stage_init.py | 69 +++++--------- tests/helpers/__init__.py | 41 +------- tests/helpers/mock.py | 51 ++++++++++ tests/helpers/tests/test_mock.py | 93 +++++++++++++++++++ ...ls.py => test_fish_speech_prompt_utils.py} | 0 ...tils.py => test_indextts2_prompt_utils.py} | 2 +- .../minicpmo_4_5/test_code2wav_model_dir.py | 24 ++--- .../models/test_minimax_music3_repo_root.py | 80 ++++++---------- .../model_loader/diffusers_loader.py | 2 +- .../diffusion/models/magi2/pipeline_magi2.py | 2 +- .../models/minimax_music3/weights.py | 2 +- 22 files changed, 236 insertions(+), 221 deletions(-) rename tests/diffusion/models/hunyuan_image3/{test_prompt_utils.py => test_hunyuan_image3_prompt_utils.py} (99%) create mode 100644 tests/helpers/mock.py create mode 100644 tests/helpers/tests/test_mock.py rename tests/model_executor/models/fish_speech/{test_prompt_utils.py => test_fish_speech_prompt_utils.py} (100%) rename tests/model_executor/models/indextts2/{test_prompt_utils.py => test_indextts2_prompt_utils.py} (98%) diff --git a/tests/diffusion/attention/test_attention_config.py b/tests/diffusion/attention/test_attention_config.py index 4a053b1bf..0b975f27c 100644 --- a/tests/diffusion/attention/test_attention_config.py +++ b/tests/diffusion/attention/test_attention_config.py @@ -42,6 +42,14 @@ from vllm_omni.diffusion.data import ( pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] +@pytest.fixture(autouse=True) +def _clear_inherited_attention_backend(monkeypatch): + # Weekly CPU exports DIFFUSION_ATTENTION_BACKEND; OmniDiffusionConfig + # treats that as an explicit default so auto-backend cases break. + # Tests that setenv the same variable still work: this fixture runs first. + monkeypatch.delenv("DIFFUSION_ATTENTION_BACKEND", raising=False) + + class TestAttentionSpec: def test_construct_no_skip_softmax(self): spec = AttentionSpec(backend="FLASH_ATTN") @@ -416,13 +424,6 @@ class TestBuildAttentionConfig: class TestOmniDiffusionConfigAttentionParsing: """Test OmniDiffusionConfig attention shorthand and structured config.""" - @pytest.fixture(autouse=True) - def _clear_diffusion_attention_backend_env(self, monkeypatch): - # OmniDiffusionConfig.__post_init__ applies DIFFUSION_ATTENTION_BACKEND via - # build_attention_config(); clear it so these tests assert config defaults, - # not whatever the process inherited from CI / sibling tests. - monkeypatch.delenv("DIFFUSION_ATTENTION_BACKEND", raising=False) - def test_diffusion_attention_backend_sets_default(self): config = OmniDiffusionConfig.from_kwargs(diffusion_attention_backend="SAGE_ATTN") assert isinstance(config.diffusion_attention_config, AttentionConfig) diff --git a/tests/diffusion/model_loader/test_hub_prefetch.py b/tests/diffusion/model_loader/test_hub_prefetch.py index 94170edcc..b17189539 100644 --- a/tests/diffusion/model_loader/test_hub_prefetch.py +++ b/tests/diffusion/model_loader/test_hub_prefetch.py @@ -3,21 +3,21 @@ import contextlib -import huggingface_hub import pytest +from tests.helpers.mock import patch_hf_snapshot_download from vllm_omni.diffusion.model_loader import hub_prefetch pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] -def test_prefetch_subfolders_propagates_revision(monkeypatch): +def test_prefetch_subfolders_propagates_revision(monkeypatch, tmp_path): calls = [] - def fake_snapshot_download(self, **kwargs): + def fake_snapshot_download(**kwargs): calls.append(kwargs) - monkeypatch.setattr(huggingface_hub.HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) monkeypatch.setattr(hub_prefetch, "_repo_prefetch_lock", lambda _model: contextlib.nullcontext()) hub_prefetch.prefetch_subfolders( diff --git a/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_it2i_multi_image.py b/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_it2i_multi_image.py index 1e0fd1590..14a854a7a 100644 --- a/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_it2i_multi_image.py +++ b/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_it2i_multi_image.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Multi-image input regression for HunyuanImage3 IT2I prompt construction. The official HunyuanImage-3.0-Instruct supports up to 3 reference images @@ -41,7 +41,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu] class FakeTokenizer: - """Recording fake tokenizer mirroring the one in test_prompt_utils. + """Recording fake tokenizer mirroring the one in test_hunyuan_image3_prompt_utils. Special token ids: `<|startoftext|>`=1, ``=2, ``=3, ``=4. encode() returns one id per character starting at diff --git a/tests/diffusion/models/hunyuan_image3/test_prompt_utils.py b/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_prompt_utils.py similarity index 99% rename from tests/diffusion/models/hunyuan_image3/test_prompt_utils.py rename to tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_prompt_utils.py index 7a467e359..a2be63fd2 100644 --- a/tests/diffusion/models/hunyuan_image3/test_prompt_utils.py +++ b/tests/diffusion/models/hunyuan_image3/test_hunyuan_image3_prompt_utils.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from __future__ import annotations import ast diff --git a/tests/diffusion/models/magi2/test_pipeline_magi2.py b/tests/diffusion/models/magi2/test_pipeline_magi2.py index 2cda7e66e..4e6057457 100644 --- a/tests/diffusion/models/magi2/test_pipeline_magi2.py +++ b/tests/diffusion/models/magi2/test_pipeline_magi2.py @@ -171,7 +171,7 @@ def test_huggingface_url_resolves_pinned_snapshot(tmp_path, monkeypatch): else pytest.fail("unexpected snapshot request") ) monkeypatch.setattr( - "vllm.transformers_utils.repo_utils.hf_api", + "vllm_omni.transformers_utils.repo_utils.hf_api", lambda: api, ) assert _resolve_checkpoint_root( diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py index 3beda5acc..dd54f00ae 100644 --- a/tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py @@ -16,6 +16,7 @@ from tests.diffusion.quantization.test_quantization_quality import ( _maybe_save_output, ) from tests.helpers.mark import hardware_marks +from tests.helpers.mock import patch_hf_snapshot_download _MINIMAX_H3_REPO = "MiniMaxAI/MiniMax-H3" _MINIMAX_H3_REVISION = "48d93ede732756e404a3b1b2f3b3a9b5a22f6cfc" @@ -258,13 +259,13 @@ def test_resolve_fl2va_model_ref(tmp_path, monkeypatch): fl2va_root.mkdir() (fl2va_root / "model_index.json").write_text("{}", encoding="utf-8") - def fake_snapshot_download(self, *, repo_id, revision, allow_patterns): + def fake_snapshot_download(*, repo_id, revision, allow_patterns): assert repo_id == _MINIMAX_H3_REPO assert revision == _MINIMAX_H3_REVISION assert allow_patterns == ["FL2VA/**"] return str(tmp_path) - monkeypatch.setattr("huggingface_hub.HfApi.snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) assert _resolve_fl2va_model_ref() == str(fl2va_root) diff --git a/tests/e2e/accuracy/ltx/test_ltx25_official_similarity.py b/tests/e2e/accuracy/ltx/test_ltx25_official_similarity.py index c8ce29c37..ba39fb204 100644 --- a/tests/e2e/accuracy/ltx/test_ltx25_official_similarity.py +++ b/tests/e2e/accuracy/ltx/test_ltx25_official_similarity.py @@ -32,7 +32,6 @@ from huggingface_hub import hf_hub_download, snapshot_download from torchmetrics.image import PeakSignalNoiseRatio, StructuralSimilarityIndexMeasure from tests.e2e.accuracy.helpers import reset_artifact_dir -from tests.helpers import skip_if_gated_repo_inaccessible from tests.helpers.mark import hardware_test OFFICIAL_REPOSITORY = "https://github.com/Lightricks/LTX-2.git" @@ -303,32 +302,6 @@ def _resolve_ltx25_image() -> Path: ) -def _require_ltx25_model_access() -> None: - configured_omni_model = os.environ.get("VLLM_TEST_LTX25_MODEL") - if not (configured_omni_model and Path(configured_omni_model).exists()): - omni_model = configured_omni_model or LTX25_OMNI_MODEL_ID - omni_revision = os.environ.get("VLLM_TEST_LTX25_MODEL_REVISION") - if omni_revision is None and omni_model == LTX25_OMNI_MODEL_ID: - omni_revision = LTX25_OMNI_MODEL_REVISION - skip_if_gated_repo_inaccessible( - omni_model, - revision=omni_revision, - filename="model_index.json", - ) - - configured_official_model = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_MODEL") - if not (configured_official_model and Path(configured_official_model).exists()): - official_model = configured_official_model or LTX25_OFFICIAL_MODEL_ID - official_revision = os.environ.get("VLLM_TEST_LTX25_OFFICIAL_MODEL_REVISION") - if official_revision is None and official_model == LTX25_OFFICIAL_MODEL_ID: - official_revision = LTX25_OFFICIAL_MODEL_REVISION - skip_if_gated_repo_inaccessible( - official_model, - revision=official_revision, - filename=LTX25_OFFICIAL_COMMON_FILES["text_encoder"], - ) - - def _ltx25_request(image: Path | None = None) -> dict[str, object]: request: dict[str, object] = { "prompt": LTX25_PROMPT, @@ -475,7 +448,6 @@ def _assert_strict_similarity( @pytest.mark.parametrize("task", ["t2v", "i2v"]) def test_ltx25_distilled_two_stage_matches_official(accuracy_artifact_root: Path, task: str) -> None: """Compare Omni with official canonical DiffVAE and matching connector weights.""" - _require_ltx25_model_access() artifact_parent = accuracy_artifact_root / "ltx_official" output_root = reset_artifact_dir(artifact_parent / f"ltx2_5_distilled_{task}") official_root, official_revision = _ltx25_official_source(artifact_parent) @@ -600,7 +572,6 @@ def test_ltx25_distilled_two_stage_matches_official(accuracy_artifact_root: Path @pytest.mark.parametrize("pipeline_mode", ["full_one_stage", "full_two_stage"]) def test_ltx25_full_matches_official(accuracy_artifact_root: Path, task: str, pipeline_mode: str) -> None: """Compare raw official Full/SFT and DiffVAE weights with Omni.""" - _require_ltx25_model_access() artifact_parent = accuracy_artifact_root / "ltx_official" output_root = reset_artifact_dir(artifact_parent / f"ltx2_5_{pipeline_mode}_{task}") model_class_name = { diff --git a/tests/e2e/offline_inference/test_hunyuanimage3.py b/tests/e2e/offline_inference/test_hunyuanimage3.py index 21bfe9b39..ab1bb5701 100644 --- a/tests/e2e/offline_inference/test_hunyuanimage3.py +++ b/tests/e2e/offline_inference/test_hunyuanimage3.py @@ -558,7 +558,7 @@ def test_shared_script_ar_path_reaches_generation( # safe against MODEL_NAME's base checkpoint missing the Instruct-only # / tokens. Wiring it here exercises the # real production path (get_ar_tokenizer_validator via the registry), - # not just the isolated fake-tokenizer coverage in test_prompt_utils.py + # not just the isolated fake-tokenizer coverage in test_hunyuan_image3_prompt_utils.py # / test_shared_script_ar_integration.py. validate_tokenizer=get_ar_tokenizer_validator(model_class_name), ) diff --git a/tests/e2e/online_serving/test_ltx25.py b/tests/e2e/online_serving/test_ltx25.py index 4975abcc0..400210a7d 100644 --- a/tests/e2e/online_serving/test_ltx25.py +++ b/tests/e2e/online_serving/test_ltx25.py @@ -7,7 +7,6 @@ import os import pytest -from tests.helpers import skip_if_gated_repo_inaccessible from tests.helpers.mark import hardware_marks from tests.helpers.media import generate_synthetic_image from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler @@ -24,16 +23,6 @@ pytestmark = [pytest.mark.diffusion, pytest.mark.slow] SINGLE_CARD_MARKS = hardware_marks(res={"cuda": "H100"}) -@pytest.fixture(scope="module", autouse=True) -def require_ltx25_model_access() -> None: - if not os.path.isdir(MODEL): - skip_if_gated_repo_inaccessible( - MODEL, - revision=MODEL_REVISION or None, - filename="model_index.json", - ) - - def _server(model_class_name: str) -> OmniServerParams: return OmniServerParams( model=MODEL, diff --git a/tests/e2e/online_serving/test_stable_audio_online_expansion.py b/tests/e2e/online_serving/test_stable_audio_online_expansion.py index fbd6bdbfd..beb51f7c9 100644 --- a/tests/e2e/online_serving/test_stable_audio_online_expansion.py +++ b/tests/e2e/online_serving/test_stable_audio_online_expansion.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """ E2E online serving test for Stable Audio Open text-to-audio diffusion. @@ -11,7 +11,6 @@ import os import pytest -from tests.helpers import skip_if_gated_repo_inaccessible from tests.helpers.mark import hardware_marks from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler @@ -32,17 +31,10 @@ def _stable_audio_server_cases(model: str): ] -@pytest.fixture -def _require_stable_audio_access() -> None: - """Skip cleanly (before the server boots) if the gated checkpoint is inaccessible.""" - skip_if_gated_repo_inaccessible(STABLE_AUDIO_TEST_MODEL) - - @pytest.mark.slow @pytest.mark.diffusion @pytest.mark.parametrize("omni_server", _stable_audio_server_cases(STABLE_AUDIO_TEST_MODEL), indirect=True) def test_stable_audio_t2a_online( - _require_stable_audio_access: None, omni_server: OmniServer, openai_client: OpenAIClientHandler, ) -> None: diff --git a/tests/engine/test_arg_utils.py b/tests/engine/test_arg_utils.py index 3b0192a79..142affab7 100644 --- a/tests/engine/test_arg_utils.py +++ b/tests/engine/test_arg_utils.py @@ -22,6 +22,7 @@ from pydantic import ValidationError from transformers import PretrainedConfig, Qwen3OmniMoeConfig from vllm.engine.arg_utils import EngineArgs +from tests.helpers.mock import patch_hf_snapshot_download from vllm_omni.config.model import OmniModelConfig from vllm_omni.engine.arg_utils import OmniEngineArgs from vllm_omni.engine.stage_init_utils import build_engine_args_dict @@ -296,7 +297,7 @@ def test_remote_tokenizer_subfolder_download_does_not_report_failure(tmp_path, m baseline_config = Mock() warning = mocker.patch("vllm_omni.engine.arg_utils.logger.warning") - monkeypatch.setattr("huggingface_hub.HfApi.snapshot_download", lambda *args, **kwargs: str(tmp_path)) + patch_hf_snapshot_download(monkeypatch, lambda *args, **kwargs: str(tmp_path), hf_home=tmp_path) monkeypatch.setattr(OmniEngineArgs, "_patch_empty_hf_config", lambda *args, **kwargs: None) monkeypatch.setattr(EngineArgs, "create_model_config", lambda _self: baseline_config) monkeypatch.setattr( diff --git a/tests/engine/test_async_omni_engine_stage_init.py b/tests/engine/test_async_omni_engine_stage_init.py index ec897bd7d..a8255f81a 100644 --- a/tests/engine/test_async_omni_engine_stage_init.py +++ b/tests/engine/test_async_omni_engine_stage_init.py @@ -14,6 +14,7 @@ import pytest from omegaconf import OmegaConf from vllm.v1.engine.utils import EngineZmqAddresses +from tests.helpers.mock import patch_hf_snapshot_download from vllm_omni.config.omni_config import OmniStageRuntimeConfig from vllm_omni.diffusion.data import AttentionConfig from vllm_omni.engine import omni_engine_base as async_omni_engine_module @@ -1566,22 +1567,20 @@ def test_build_engine_args_pulls_stage_subdirs_missing_from_cached_snapshot(monk never materialized. Stage init must fetch exactly those subfolders instead of joining onto a path that exists nowhere. """ - from huggingface_hub import HfApi - from vllm_omni.engine.stage_init_utils import build_engine_args_dict snapshot = tmp_path / "snapshots" / "deadbeef" _make_snapshot(snapshot, ["tokenizer"]) calls = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): calls.append(kwargs) if kwargs.get("local_files_only"): return str(snapshot) _make_snapshot(snapshot, ["language_model"]) return str(snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) stage_cfg = types.SimpleNamespace( stage_id=0, @@ -1601,18 +1600,16 @@ def test_build_engine_args_pulls_stage_subdirs_missing_from_cached_snapshot(monk def test_build_engine_args_skips_hub_call_when_cached_snapshot_is_complete(monkeypatch, tmp_path): """A warm cache stays offline-friendly: no outgoing Hub request.""" - from huggingface_hub import HfApi - from vllm_omni.engine.stage_init_utils import build_engine_args_dict snapshot = tmp_path / "snapshots" / "deadbeef" _make_snapshot(snapshot, ["language_model", "tokenizer"]) - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): assert kwargs.get("local_files_only"), "warm cache must not reach the Hub" return str(snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) stage_cfg = types.SimpleNamespace( stage_id=0, @@ -1633,8 +1630,6 @@ def test_build_engine_args_redownloads_a_partial_subdir(monkeypatch, tmp_path): that local path leaves vLLM's loader without any fallback for the missing weights. """ - from huggingface_hub import HfApi - from vllm_omni.engine.stage_init_utils import build_engine_args_dict snapshot = tmp_path / "snapshots" / "deadbeef" @@ -1644,14 +1639,14 @@ def test_build_engine_args_redownloads_a_partial_subdir(monkeypatch, tmp_path): _make_snapshot(snapshot, ["tokenizer"]) calls = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): calls.append(kwargs) if kwargs.get("local_files_only"): return str(snapshot) _make_snapshot(snapshot, ["language_model"]) return str(snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) stage_cfg = types.SimpleNamespace( stage_id=0, @@ -1672,8 +1667,6 @@ def test_build_engine_args_redownloads_when_index_lists_missing_shards(monkeypat The index downloads early; accepting it as proof of weights converts the Hub ID into a local path vLLM cannot fetch the remaining shards for. """ - from huggingface_hub import HfApi - from vllm_omni.engine.stage_init_utils import build_engine_args_dict snapshot = tmp_path / "snapshots" / "deadbeef" @@ -1693,13 +1686,13 @@ def test_build_engine_args_redownloads_when_index_lists_missing_shards(monkeypat _make_snapshot(snapshot, ["tokenizer"]) calls = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): calls.append(kwargs) if not kwargs.get("local_files_only"): (partial / "model-00002-of-00002.safetensors").write_text("x") return str(snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) stage_cfg = types.SimpleNamespace( stage_id=0, @@ -1716,8 +1709,6 @@ def test_build_engine_args_redownloads_when_index_lists_missing_shards(monkeypat def test_build_engine_args_redownloads_shards_without_their_index(monkeypatch, tmp_path): """Shard-named weights always ship an index; one without it is partial.""" - from huggingface_hub import HfApi - from vllm_omni.engine.stage_init_utils import build_engine_args_dict snapshot = tmp_path / "snapshots" / "deadbeef" @@ -1727,7 +1718,7 @@ def test_build_engine_args_redownloads_shards_without_their_index(monkeypatch, t _make_snapshot(snapshot, ["tokenizer"]) calls = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): calls.append(kwargs) if not kwargs.get("local_files_only"): shards = {f"w{i}.weight": f"model-0000{i}-of-00004.safetensors" for i in range(1, 5)} @@ -1736,7 +1727,7 @@ def test_build_engine_args_redownloads_shards_without_their_index(monkeypatch, t (partial / shard).write_text("x") return str(snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) stage_cfg = types.SimpleNamespace( stage_id=0, @@ -1752,8 +1743,6 @@ def test_build_engine_args_redownloads_shards_without_their_index(monkeypatch, t def test_build_engine_args_redownloads_a_tokenizer_folder_without_vocabulary(monkeypatch, tmp_path): """Templates and configs download first; alone they are not a tokenizer.""" - from huggingface_hub import HfApi - from vllm_omni.engine.stage_init_utils import build_engine_args_dict snapshot = tmp_path / "snapshots" / "deadbeef" @@ -1763,13 +1752,13 @@ def test_build_engine_args_redownloads_a_tokenizer_folder_without_vocabulary(mon (partial / "chat_template.jinja").write_text("{{ messages }}") calls = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): calls.append(kwargs) if not kwargs.get("local_files_only"): _make_snapshot(snapshot, ["tokenizer"]) return str(snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) stage_cfg = types.SimpleNamespace( stage_id=0, @@ -1787,19 +1776,17 @@ def test_build_engine_args_redownloads_a_tokenizer_folder_without_vocabulary(mon def test_build_engine_args_forwards_revision_and_download_dir(monkeypatch, tmp_path): """revision/download_dir must shape snapshot selection (they cannot be corrected downstream once the repo ID is a local path).""" - from huggingface_hub import HfApi - from vllm_omni.engine.stage_init_utils import build_engine_args_dict snapshot = tmp_path / "snapshots" / "pinned" _make_snapshot(snapshot, ["language_model", "tokenizer"]) seen = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): seen.append(kwargs) return str(snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) stage_cfg = types.SimpleNamespace( stage_id=0, @@ -1824,8 +1811,6 @@ def test_build_engine_args_forwards_revision_and_download_dir(monkeypatch, tmp_p def test_build_engine_args_resolves_tokenizer_revision_separately(monkeypatch, tmp_path): """A tokenizer pinned to a different revision resolves against its own snapshot.""" - from huggingface_hub import HfApi - from vllm_omni.engine.stage_init_utils import build_engine_args_dict model_snapshot = tmp_path / "snapshots" / "model-rev" @@ -1833,12 +1818,12 @@ def test_build_engine_args_resolves_tokenizer_revision_separately(monkeypatch, t tokenizer_snapshot = tmp_path / "snapshots" / "tok-rev" _make_snapshot(tokenizer_snapshot, ["tokenizer"]) - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): if kwargs.get("revision") == "tok-rev": return str(tokenizer_snapshot) return str(model_snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) stage_cfg = types.SimpleNamespace( stage_id=0, @@ -1861,8 +1846,6 @@ def test_build_engine_args_resolves_tokenizer_revision_separately(monkeypatch, t def test_build_engine_args_resolves_root_tokenizer_revision_separately(monkeypatch, tmp_path): """An empty tokenizer_subdir targets the snapshot root and still honors its own revision.""" - from huggingface_hub import HfApi - from vllm_omni.engine.stage_init_utils import build_engine_args_dict model_snapshot = tmp_path / "snapshots" / "model-rev" @@ -1875,12 +1858,12 @@ def test_build_engine_args_resolves_root_tokenizer_revision_separately(monkeypat tokenizer_snapshot.mkdir(parents=True, exist_ok=True) (tokenizer_snapshot / _SUBDIR_ARTIFACT["tokenizer"]).write_text("x") - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): if kwargs.get("revision") == "tok-rev": return str(tokenizer_snapshot) return str(model_snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) stage_cfg = types.SimpleNamespace( stage_id=0, @@ -1902,19 +1885,17 @@ def test_build_engine_args_resolves_root_tokenizer_revision_separately(monkeypat def test_build_engine_args_fails_closed_when_subdir_cannot_be_downloaded(monkeypatch, tmp_path): """An undownloadable subfolder raises here instead of reaching HuggingFace.""" - from huggingface_hub import HfApi - from vllm_omni.engine.stage_init_utils import build_engine_args_dict snapshot = tmp_path / "snapshots" / "deadbeef" _make_snapshot(snapshot, ["tokenizer"]) - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): if kwargs.get("local_files_only"): return str(snapshot) raise OSError("offline") - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) stage_cfg = types.SimpleNamespace( stage_id=0, @@ -1927,16 +1908,14 @@ def test_build_engine_args_fails_closed_when_subdir_cannot_be_downloaded(monkeyp build_engine_args_dict(stage_cfg, "MiniMaxAI/MiniMax-Music3") -def test_build_engine_args_fails_closed_on_cold_cache_instead_of_joining_repo_id(monkeypatch): +def test_build_engine_args_fails_closed_on_cold_cache_instead_of_joining_repo_id(monkeypatch, tmp_path): """With nothing cached, the stage must not join a subdir onto the repo id.""" - from huggingface_hub import HfApi - from vllm_omni.engine.stage_init_utils import build_engine_args_dict - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): raise OSError("offline") - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) stage_cfg = types.SimpleNamespace( stage_id=0, diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 9120e183f..af7977906 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -3,49 +3,10 @@ """Shared, importable test helper utilities. -Submodules (``assertions``, ``clean``, ``client``, ``media``, ``runtime``, …) are imported +Submodules (``assertions``, ``clean``, ``client``, ``media``, ``mock``, ``runtime``, …) are imported explicitly by callers. Unit tests for these helpers live under ``tests/``. Avoid star-importing everything here: that ran before refactor only inside the old monolithic ``conftest``; a greedy ``__init__`` changes import order and can affect in-process Omni (``OmniRunner`` / offline e2e) vs subprocess-based ``OmniServer`` tests. """ - -from __future__ import annotations - - -def skip_if_gated_repo_inaccessible( - repo_id: str, - *, - revision: str | None = None, - filename: str = "config.json", -) -> None: - """Skip the test if a gated HuggingFace repo is not accessible. - - Tries to download ``filename`` via ``hf_hub_download``, which performs an - actual file-access check (unlike ``HfApi().model_info()`` - that only checks metadata). If the token has metadata access but not - file-download access, ``hf_hub_download`` will raise ``GatedRepoError`` - and we skip cleanly. - """ - try: - from huggingface_hub.errors import GatedRepoError, RepositoryNotFoundError - - from vllm_omni.transformers_utils.repo_utils import hf_api - except Exception: - return - try: - hf_api().hf_hub_download(repo_id=repo_id, filename=filename, revision=revision) - except GatedRepoError as exc: - import pytest - - pytest.skip( - f"Skipping: gated HF repo {repo_id!r} inaccessible to the current " - f"HF_TOKEN ({exc}). See docs/contributing/ci/hf_credentials.md." - ) - except RepositoryNotFoundError as exc: - import pytest - - pytest.skip(f"Skipping: HF repo {repo_id!r} not found ({exc}).") - except Exception: - return diff --git a/tests/helpers/mock.py b/tests/helpers/mock.py new file mode 100644 index 000000000..47ce8f79d --- /dev/null +++ b/tests/helpers/mock.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Shared pytest mocks for unit tests. + +Add reusable ``monkeypatch`` helpers here instead of a new module per target. +Callers import explicitly: ``from tests.helpers.mock import ...``. +""" + +from __future__ import annotations + +from collections.abc import Callable +from os import PathLike +from pathlib import Path +from typing import Any + + +def patch_hf_snapshot_download( + monkeypatch: Any, + fake: Callable[..., Any], + *, + hf_home: str | PathLike[str] | None = None, +) -> None: + """Replace ``snapshot_download`` on the cached ``hf_api()`` singleton. + + Production code calls ``hf_api().snapshot_download``. Patching + ``huggingface_hub.HfApi.snapshot_download`` misses that instance, so + weekly CPU jobs talk to the real Hub / ``HF_HOME`` cache. + + ``fake`` is installed on the instance, so it is called without ``self``. + + Pass ``hf_home`` (typically pytest ``tmp_path``) to isolate the Hub cache. + ``repo_utils`` imports ``huggingface_hub`` before tests can ``setenv``, so + ``constants.HF_HUB_CACHE`` is already frozen from the process environment + (weekly: ``/fsx/hf_cache``). Downloads without ``cache_dir`` use that + constant, not ``HF_HOME``. Patch both the env and the imported constants + so a missed instance mock cannot read the builder cache. + """ + from huggingface_hub import constants as hub_constants + + from vllm_omni.transformers_utils import repo_utils + + monkeypatch.setattr(repo_utils.hf_api(), "snapshot_download", fake) + if hf_home is not None: + hf_home_str = str(hf_home) + hub_cache = str(Path(hf_home_str) / "hub") + monkeypatch.setenv("HF_HOME", hf_home_str) + monkeypatch.setenv("HF_HUB_CACHE", hub_cache) + monkeypatch.setattr(hub_constants, "HF_HOME", hf_home_str) + monkeypatch.setattr(hub_constants, "default_cache_path", hub_cache) + monkeypatch.setattr(hub_constants, "HF_HUB_CACHE", hub_cache) + monkeypatch.setattr(hub_constants, "HUGGINGFACE_HUB_CACHE", hub_cache) diff --git a/tests/helpers/tests/test_mock.py b/tests/helpers/tests/test_mock.py new file mode 100644 index 000000000..f30187395 --- /dev/null +++ b/tests/helpers/tests/test_mock.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import os +from pathlib import Path + +import pytest + +from tests.helpers.mock import patch_hf_snapshot_download + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_patch_hf_snapshot_download_binds_instance_without_self(monkeypatch): + from vllm_omni.transformers_utils.repo_utils import hf_api + + calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + def fake(*args, **kwargs): + calls.append((args, kwargs)) + return "ok" + + patch_hf_snapshot_download(monkeypatch, fake) + assert hf_api().snapshot_download("org/model", revision="abc") == "ok" + assert calls == [(("org/model",), {"revision": "abc"})] + + +def test_patch_hf_snapshot_download_sets_hf_home(monkeypatch, tmp_path): + hf_home = tmp_path / "hf-home" + patch_hf_snapshot_download(monkeypatch, lambda *args, **kwargs: "ok", hf_home=hf_home) + assert os.environ["HF_HOME"] == str(hf_home) + from huggingface_hub import constants + + assert constants.HF_HOME == str(hf_home) + assert constants.HF_HUB_CACHE == str(hf_home / "hub") + assert constants.HUGGINGFACE_HUB_CACHE == str(hf_home / "hub") + assert constants.default_cache_path == str(hf_home / "hub") + + +def test_patch_hf_snapshot_download_leaves_hf_home_when_omitted(monkeypatch): + from huggingface_hub import constants + + monkeypatch.setenv("HF_HOME", "/original-hf-home") + original_cache = constants.HF_HUB_CACHE + original_default = constants.default_cache_path + patch_hf_snapshot_download(monkeypatch, lambda *args, **kwargs: "ok") + assert os.environ["HF_HOME"] == "/original-hf-home" + assert constants.HF_HUB_CACHE == original_cache + assert constants.default_cache_path == original_default + + +def test_patch_hf_snapshot_download_overrides_preimported_hub_cache(monkeypatch, tmp_path): + """Hub is imported before the helper; ``HF_HOME`` env alone does not move the cache. + + ``repo_utils`` / ``huggingface_hub`` compute ``constants.HF_HUB_CACHE`` at + import time. Weekly CPU already imported them with ``HF_HOME=/fsx/hf_cache``. + A download that bypasses the instance mock uses that constant, not the env. + """ + from huggingface_hub import constants, snapshot_download + from huggingface_hub.errors import LocalEntryNotFoundError + + from vllm_omni.transformers_utils.repo_utils import hf_api + + hf_api() + + poisoned_home = tmp_path / "fsx-hf-cache" + poisoned_cache = poisoned_home / "hub" + repo_dir = poisoned_cache / "models--org--model" + (repo_dir / "refs").mkdir(parents=True) + (repo_dir / "refs" / "main").write_text("deadbeef", encoding="utf-8") + snapshot = repo_dir / "snapshots" / "deadbeef" + snapshot.mkdir(parents=True) + (snapshot / "config.json").write_text("{}", encoding="utf-8") + + monkeypatch.setenv("HF_HOME", str(poisoned_home)) + monkeypatch.setattr(constants, "HF_HOME", str(poisoned_home)) + monkeypatch.setattr(constants, "HF_HUB_CACHE", str(poisoned_cache)) + monkeypatch.setattr(constants, "HUGGINGFACE_HUB_CACHE", str(poisoned_cache)) + + found = snapshot_download("org/model", local_files_only=True) + assert Path(found).resolve() == snapshot.resolve() + + isolated = tmp_path / "isolated-hf-home" + patch_hf_snapshot_download(monkeypatch, lambda *args, **kwargs: "mocked", hf_home=isolated) + + assert os.environ["HF_HOME"] == str(isolated) + assert os.environ["HF_HUB_CACHE"] == str(isolated / "hub") + assert constants.HF_HOME == str(isolated) + assert constants.HF_HUB_CACHE == str(isolated / "hub") + assert constants.HUGGINGFACE_HUB_CACHE == str(isolated / "hub") + assert constants.default_cache_path == str(isolated / "hub") + with pytest.raises(LocalEntryNotFoundError): + snapshot_download("org/model", local_files_only=True) diff --git a/tests/model_executor/models/fish_speech/test_prompt_utils.py b/tests/model_executor/models/fish_speech/test_fish_speech_prompt_utils.py similarity index 100% rename from tests/model_executor/models/fish_speech/test_prompt_utils.py rename to tests/model_executor/models/fish_speech/test_fish_speech_prompt_utils.py diff --git a/tests/model_executor/models/indextts2/test_prompt_utils.py b/tests/model_executor/models/indextts2/test_indextts2_prompt_utils.py similarity index 98% rename from tests/model_executor/models/indextts2/test_prompt_utils.py rename to tests/model_executor/models/indextts2/test_indextts2_prompt_utils.py index fe73e69a5..29c86567f 100644 --- a/tests/model_executor/models/indextts2/test_prompt_utils.py +++ b/tests/model_executor/models/indextts2/test_indextts2_prompt_utils.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project import importlib.util from pathlib import Path diff --git a/tests/model_executor/models/minicpmo_4_5/test_code2wav_model_dir.py b/tests/model_executor/models/minicpmo_4_5/test_code2wav_model_dir.py index 8d5aeade6..fced5ae46 100644 --- a/tests/model_executor/models/minicpmo_4_5/test_code2wav_model_dir.py +++ b/tests/model_executor/models/minicpmo_4_5/test_code2wav_model_dir.py @@ -12,9 +12,9 @@ from __future__ import annotations from types import SimpleNamespace -import huggingface_hub import pytest +from tests.helpers.mock import patch_hf_snapshot_download from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_code2wav import ( MiniCPMO45Code2Wav, _resolve_model_dir, @@ -23,28 +23,28 @@ from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_code2wav import ( pytestmark = [pytest.mark.core_model, pytest.mark.cpu] -def _no_hub(monkeypatch): +def _no_hub(monkeypatch, tmp_path): def _fail(*args, **kwargs): # pragma: no cover - must not be reached raise AssertionError("snapshot_download must not be called here") - monkeypatch.setattr(huggingface_hub.HfApi, "snapshot_download", _fail) + patch_hf_snapshot_download(monkeypatch, _fail, hf_home=tmp_path) def test_local_directory_is_returned_unchanged(tmp_path, monkeypatch): - _no_hub(monkeypatch) + _no_hub(monkeypatch, tmp_path) assert _resolve_model_dir(str(tmp_path)) == str(tmp_path) def test_repo_id_resolves_via_snapshot_download(tmp_path, monkeypatch): calls = {} - def _fake_snapshot_download(self, model_ref, revision=None, allow_patterns=None): + def _fake_snapshot_download(model_ref, revision=None, allow_patterns=None): calls["model_ref"] = model_ref calls["revision"] = revision calls["allow_patterns"] = allow_patterns return str(tmp_path / "snapshot") - monkeypatch.setattr(huggingface_hub.HfApi, "snapshot_download", _fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, _fake_snapshot_download, hf_home=tmp_path) resolved = _resolve_model_dir("openbmb/MiniCPM-o-4_5", revision="abc123") assert resolved == str(tmp_path / "snapshot") assert calls["model_ref"] == "openbmb/MiniCPM-o-4_5" @@ -52,18 +52,18 @@ def test_repo_id_resolves_via_snapshot_download(tmp_path, monkeypatch): assert calls["allow_patterns"] == ["assets/*"] -def test_snapshot_download_failure_propagates(monkeypatch): +def test_snapshot_download_failure_propagates(monkeypatch, tmp_path): def _raise(*args, **kwargs): raise FileNotFoundError("offline and not cached") - monkeypatch.setattr(huggingface_hub.HfApi, "snapshot_download", _raise) + patch_hf_snapshot_download(monkeypatch, _raise, hf_home=tmp_path) with pytest.raises(FileNotFoundError): _resolve_model_dir("openbmb/MiniCPM-o-4_5") -def test_init_with_fake_path_does_not_resolve(monkeypatch): +def test_init_with_fake_path_does_not_resolve(monkeypatch, tmp_path): """Mirrors the CPU-test construction: fake model path, no ``revision``.""" - _no_hub(monkeypatch) + _no_hub(monkeypatch, tmp_path) config = SimpleNamespace( model_config=SimpleNamespace( model="/fake/model", @@ -75,8 +75,8 @@ def test_init_with_fake_path_does_not_resolve(monkeypatch): assert model._default_prompt_wav == "/fake/model/assets/HT_ref_audio.wav" -def test_default_prompt_wav_follows_resolved_model_path(monkeypatch): - _no_hub(monkeypatch) +def test_default_prompt_wav_follows_resolved_model_path(monkeypatch, tmp_path): + _no_hub(monkeypatch, tmp_path) config = SimpleNamespace( model_config=SimpleNamespace( model="openbmb/MiniCPM-o-4_5", diff --git a/tests/model_executor/models/test_minimax_music3_repo_root.py b/tests/model_executor/models/test_minimax_music3_repo_root.py index 182635ab7..6cac9d559 100644 --- a/tests/model_executor/models/test_minimax_music3_repo_root.py +++ b/tests/model_executor/models/test_minimax_music3_repo_root.py @@ -12,6 +12,7 @@ from __future__ import annotations import pytest +from tests.helpers.mock import patch_hf_snapshot_download from vllm_omni.model_executor.models.minimax_music3.weights import ( _COMPONENT_WEIGHT_NAME, _REQUIRED_COMPONENTS, @@ -47,16 +48,14 @@ def test_resolve_repo_root_walks_up_from_a_model_subdir(tmp_path): def test_resolve_repo_root_resolves_a_hub_id_to_a_local_snapshot(monkeypatch, tmp_path): """A hub id must resolve through the cache, not against the working directory.""" - from huggingface_hub import HfApi - root = _make_root(tmp_path / "snapshots" / "deadbeef") seen = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): seen.append(kwargs) return str(root) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) assert resolve_repo_root("MiniMaxAI/MiniMax-Music3") == root # Cache-first, and only the component folders. @@ -66,18 +65,16 @@ def test_resolve_repo_root_resolves_a_hub_id_to_a_local_snapshot(monkeypatch, tm def test_resolve_repo_root_falls_back_to_the_hub_when_the_cache_is_incomplete(monkeypatch, tmp_path): - from huggingface_hub import HfApi - root = _make_root(tmp_path / "snapshots" / "deadbeef") calls = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): calls.append(kwargs) if kwargs.get("local_files_only"): raise OSError("incomplete snapshot") return str(root) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) assert resolve_repo_root("MiniMaxAI/MiniMax-Music3") == root assert len(calls) == 2 @@ -89,19 +86,17 @@ def test_resolve_repo_root_retries_online_when_the_local_lookup_returns_a_partia A returned partial root must fall through to the online call all the same, or startup fails without ever consulting the Hub. """ - from huggingface_hub import HfApi - snapshot = tmp_path / "models--MiniMaxAI--MiniMax-Music3" / "snapshots" / "deadbeef" (snapshot / "language_model").mkdir(parents=True) calls = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): calls.append(kwargs) if not kwargs.get("local_files_only"): _make_root(snapshot) return str(snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) assert resolve_repo_root("MiniMaxAI/MiniMax-Music3") == snapshot assert [bool(call.get("local_files_only")) for call in calls] == [True, False] @@ -109,32 +104,28 @@ def test_resolve_repo_root_retries_online_when_the_local_lookup_returns_a_partia def test_resolve_repo_root_redownloads_weightless_marker_dirs(monkeypatch, tmp_path): """Marker folders without their component weights are an interrupted download.""" - from huggingface_hub import HfApi - snapshot = tmp_path / "models--MiniMaxAI--MiniMax-Music3" / "snapshots" / "deadbeef" for marker in _ROOT_MARKERS: (snapshot / marker).mkdir(parents=True) calls = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): calls.append(kwargs) if not kwargs.get("local_files_only"): _make_root(snapshot) return str(snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) assert resolve_repo_root("MiniMaxAI/MiniMax-Music3") == snapshot assert any(not call.get("local_files_only") for call in calls) -def test_resolve_repo_root_reports_the_original_reference_when_unresolvable(monkeypatch): - from huggingface_hub import HfApi - - def fake_snapshot_download(self, repo_id, **kwargs): +def test_resolve_repo_root_reports_the_original_reference_when_unresolvable(monkeypatch, tmp_path): + def fake_snapshot_download(repo_id, **kwargs): raise OSError("offline") - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) with pytest.raises(FileNotFoundError, match="MiniMaxAI/MiniMax-Music3"): resolve_repo_root("MiniMaxAI/MiniMax-Music3") @@ -147,19 +138,17 @@ def test_resolve_repo_root_downloads_components_for_a_cold_cache_model_subdir(mo the marker walk fails although the repo id is recoverable from the cache layout and one snapshot_download away from working. """ - from huggingface_hub import HfApi - snapshot = tmp_path / "models--MiniMaxAI--MiniMax-Music3" / "snapshots" / "deadbeef" (snapshot / "language_model").mkdir(parents=True) (snapshot / "tokenizer").mkdir() seen = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): seen.append(repo_id) _make_root(snapshot) return str(snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) assert resolve_repo_root(str(snapshot / "language_model")) == snapshot assert seen == ["MiniMaxAI/MiniMax-Music3"] @@ -167,12 +156,11 @@ def test_resolve_repo_root_downloads_components_for_a_cold_cache_model_subdir(mo def test_resolve_repo_root_does_not_guess_a_repo_id_for_plain_directories(monkeypatch, tmp_path): """A directory outside the HF cache has no recoverable repo id.""" - from huggingface_hub import HfApi - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): raise AssertionError("must not reach the Hub for a plain directory") - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) plain = tmp_path / "language_model" plain.mkdir() @@ -187,8 +175,6 @@ def test_resolve_repo_root_rejects_a_snapshot_missing_condition_encoder(monkeypa acoustic stage would still fail, so completeness must cover every required component rather than the identification markers alone. """ - from huggingface_hub import HfApi - snapshot = tmp_path / "models--MiniMaxAI--MiniMax-Music3" / "snapshots" / "deadbeef" for marker in _ROOT_MARKERS: folder = snapshot / marker @@ -196,13 +182,13 @@ def test_resolve_repo_root_rejects_a_snapshot_missing_condition_encoder(monkeypa (folder / _COMPONENT_WEIGHT_NAME).write_bytes(b"x") repaired = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): if not kwargs.get("local_files_only"): repaired.append(repo_id) _make_root(snapshot) return str(snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) assert resolve_repo_root(str(snapshot)) == snapshot assert repaired == ["MiniMaxAI/MiniMax-Music3"] @@ -210,8 +196,6 @@ def test_resolve_repo_root_rejects_a_snapshot_missing_condition_encoder(monkeypa def test_resolve_repo_root_rejects_a_partial_shard_run(monkeypatch, tmp_path): """One shard of a multi-shard component satisfies a bare glob but cannot load.""" - from huggingface_hub import HfApi - stem = _COMPONENT_WEIGHT_NAME.removesuffix(".safetensors") snapshot = tmp_path / "models--MiniMaxAI--MiniMax-Music3" / "snapshots" / "deadbeef" for component in _REQUIRED_COMPONENTS: @@ -223,14 +207,14 @@ def test_resolve_repo_root_rejects_a_partial_shard_run(monkeypatch, tmp_path): (partial / f"{stem}-00001-of-00003.safetensors").write_bytes(b"x") repaired = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): if not kwargs.get("local_files_only"): repaired.append(repo_id) for index in (2, 3): (partial / f"{stem}-0000{index}-of-00003.safetensors").write_bytes(b"x") return str(snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) assert resolve_repo_root(str(snapshot)) == snapshot assert repaired == ["MiniMaxAI/MiniMax-Music3"] @@ -242,17 +226,15 @@ def test_resolve_repo_root_threads_revision_and_download_dir(monkeypatch, tmp_pa Resolving components against the default branch while the AR backbone is pinned elsewhere would combine weights from two different commits. """ - from huggingface_hub import HfApi - snapshot = tmp_path / "snapshot" calls = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): calls.append(kwargs) _make_root(snapshot) return str(snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) assert resolve_repo_root("MiniMaxAI/MiniMax-Music3", revision="abc123", download_dir="/tmp/hub-cache") == snapshot assert calls @@ -266,18 +248,16 @@ def test_resolve_repo_root_reuses_the_snapshot_revision_from_the_cache_path(monk Re-resolving the repo id alone would fetch the default branch rather than the snapshot the caller is already sitting in. """ - from huggingface_hub import HfApi - snapshot = tmp_path / "models--MiniMaxAI--MiniMax-Music3" / "snapshots" / "cafebabe" (snapshot / "language_model").mkdir(parents=True) calls = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): calls.append(kwargs) _make_root(snapshot) return str(snapshot) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) assert resolve_repo_root(str(snapshot / "language_model")) == snapshot assert calls @@ -292,8 +272,6 @@ def test_resolve_repo_root_accepts_shard_names_the_loader_can_read(monkeypatch, loadable. Judging it incomplete would force a needless re-download and, with no network, fail on weights that are present and readable. """ - from huggingface_hub import HfApi - stem = _COMPONENT_WEIGHT_NAME.removesuffix(".safetensors") root = tmp_path / "snapshot" for component in _REQUIRED_COMPONENTS: @@ -303,18 +281,16 @@ def test_resolve_repo_root_accepts_shard_names_the_loader_can_read(monkeypatch, (unsharded / _COMPONENT_WEIGHT_NAME).unlink() (unsharded / f"{stem}-fp16.safetensors").write_bytes(b"x") - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): raise AssertionError("must not reach the Hub for readable weights") - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) assert resolve_repo_root(str(root)) == root def test_resolve_repo_root_still_rejects_a_partial_run_beside_an_extra_file(monkeypatch, tmp_path): """An unparsable extra must not mask a genuinely short ``-of-`` run.""" - from huggingface_hub import HfApi - stem = _COMPONENT_WEIGHT_NAME.removesuffix(".safetensors") root = tmp_path / "snapshot" for component in _REQUIRED_COMPONENTS: @@ -326,13 +302,13 @@ def test_resolve_repo_root_still_rejects_a_partial_run_beside_an_extra_file(monk (partial / f"{stem}-extra.safetensors").write_bytes(b"x") repaired = [] - def fake_snapshot_download(self, repo_id, **kwargs): + def fake_snapshot_download(repo_id, **kwargs): repaired.append(repo_id) for index in (2, 3): (partial / f"{stem}-0000{index}-of-00003.safetensors").write_bytes(b"x") return str(root) - monkeypatch.setattr(HfApi, "snapshot_download", fake_snapshot_download) + patch_hf_snapshot_download(monkeypatch, fake_snapshot_download, hf_home=tmp_path) with pytest.raises(FileNotFoundError, match="components are incomplete"): resolve_repo_root(str(root)) diff --git a/vllm_omni/diffusion/model_loader/diffusers_loader.py b/vllm_omni/diffusion/model_loader/diffusers_loader.py index c47eab7f7..7cbff273c 100644 --- a/vllm_omni/diffusion/model_loader/diffusers_loader.py +++ b/vllm_omni/diffusion/model_loader/diffusers_loader.py @@ -25,7 +25,6 @@ from vllm.model_executor.model_loader.weight_utils import ( pt_weights_iterator, safetensors_weights_iterator, ) -from vllm.transformers_utils.repo_utils import hf_api from vllm.utils.import_utils import resolve_obj_by_qualname from vllm.utils.torch_utils import set_default_torch_dtype @@ -54,6 +53,7 @@ from vllm_omni.diffusion.offloader.module_collector import ModuleDiscovery from vllm_omni.diffusion.offloader.offload_plan import get_offload_plan from vllm_omni.diffusion.registry import initialize_model from vllm_omni.model_executor.model_loader.weight_utils import download_weights_from_hf_specific +from vllm_omni.transformers_utils.repo_utils import hf_api # download_gguf was removed from upstream vLLM (commit 6635279d8). diff --git a/vllm_omni/diffusion/models/magi2/pipeline_magi2.py b/vllm_omni/diffusion/models/magi2/pipeline_magi2.py index 910e0d074..52b95fa4c 100644 --- a/vllm_omni/diffusion/models/magi2/pipeline_magi2.py +++ b/vllm_omni/diffusion/models/magi2/pipeline_magi2.py @@ -225,7 +225,7 @@ def _resolve_checkpoint_root(model: str, revision: str | None) -> str: "MAGI-2 expects a local checkpoint directory or the official " f"model ID {MAGI2_MODEL_ID!r}; got {model!r}." ) - from vllm.transformers_utils.repo_utils import hf_api + from vllm_omni.transformers_utils.repo_utils import hf_api pinned_revision = revision or MAGI2_MODEL_REVISION logger.warning( diff --git a/vllm_omni/model_executor/models/minimax_music3/weights.py b/vllm_omni/model_executor/models/minimax_music3/weights.py index b019ff461..912395a81 100644 --- a/vllm_omni/model_executor/models/minimax_music3/weights.py +++ b/vllm_omni/model_executor/models/minimax_music3/weights.py @@ -119,7 +119,7 @@ def _download_component_snapshot( components against the default branch and the default cache while the AR backbone was pinned elsewhere would silently mix weights from two commits. """ - from vllm.transformers_utils.repo_utils import hf_api + from vllm_omni.transformers_utils.repo_utils import hf_api kwargs: dict[str, object] = {"allow_patterns": list(_COMPONENT_PATTERNS)} if revision is not None: From d397a2f547a368643f10e2b0dd65ccc5bcf48edb Mon Sep 17 00:00:00 2001 From: wuli666 <421774554@qq.com> Date: Tue, 22 Sep 2026 00:24:42 +0800 Subject: [PATCH 15/21] [Bugfix] Refresh duplex client defaults after session updates (#7784) Signed-off-by: wuli666 <421774554@qq.com> --- tests/clients/test_duplex_client.py | 48 +++++++++++++++++++++++++++++ vllm_omni/clients/duplex.py | 14 +++++++++ 2 files changed, 62 insertions(+) diff --git a/tests/clients/test_duplex_client.py b/tests/clients/test_duplex_client.py index 75ef06e3f..7313828ce 100644 --- a/tests/clients/test_duplex_client.py +++ b/tests/clients/test_duplex_client.py @@ -13,6 +13,7 @@ from __future__ import annotations import asyncio import base64 import json +import struct import wave from collections.abc import Callable from typing import Any @@ -33,6 +34,7 @@ from vllm_omni.clients.duplex import ( ReconnectPolicy, SessionConfig, SessionResumed, + SessionUpdated, build_realtime_url, chunk_period_ms, duplex_unit_boundary_ms, @@ -797,6 +799,52 @@ async def test_inline_client_submits_typed_commands_in_order(): assert ack.played_ms == 120 and ack.response_id == "resp-1" +@pytest.mark.asyncio +@pytest.mark.parametrize("accepted", [True, False]) +@pytest.mark.parametrize( + "patch", + [ + {"input_audio_format": "pcm_f32le", "sample_rate_hz": 24_000}, + {"audio": {"input": {"format": "pcm_f32le", "sample_rate_hz": 24_000}}}, + ], +) +async def test_inline_input_defaults_change_only_after_session_updated(accepted, patch): + client, omni = make_inline_client() + patch = {**patch, "overlap_silence_rms": 0.1} + async with client: + handle = omni.handles[0] + previous_session = dict(client.session_info) + await client.send({"type": "session.update", "session": patch}) + # Sending a patch is not an acknowledgement of its acceptance. + assert client.session_info == previous_session + assert client._input_defaults().input_sample_rate_hz == 16_000 + updated_session = {**previous_session, **patch} + event: dict[str, object] = ( + {"type": "session.updated", "session": updated_session} + if accepted + else {"type": "error", "error": {"code": "invalid_session_update", "message": "rejected"}} + ) + handle.feed(event) + received = await client.wait_for("session.updated" if accepted else "error", timeout_s=2.0) + assert isinstance(received, SessionUpdated if accepted else ErrorEvent) + + pcm = struct.pack(" dict[str, object]: + value = self.raw.get("session") + return value if isinstance(value, dict) else {} + + class SessionClosed(DuplexEvent): pass @@ -456,6 +466,7 @@ class ErrorEvent(DuplexEvent): _EVENT_TYPES: dict[str, type[DuplexEvent]] = { "session.created": SessionCreated, "session.resumed": SessionResumed, + "session.updated": SessionUpdated, "session.closed": SessionClosed, "session.expired": SessionExpired, "response.created": ResponseCreated, @@ -1011,6 +1022,9 @@ class DuplexClientBase(ABC): self._adopt_session(event) elif isinstance(event, SessionCreated): self._adopt_session(event) + elif isinstance(event, SessionUpdated): + if event.session: + self.session_info = event.session elif isinstance(event, ResponseCreated): response_id = event.response_id if response_id and response_id not in self._responses: From 62b15142bdab8c80eb83473f65e51774d1a2521b Mon Sep 17 00:00:00 2001 From: NumberWan Date: Tue, 22 Sep 2026 00:26:54 +0800 Subject: [PATCH 16/21] [Model] Integrate AURA into Full-Duplex Runtime (#7633) --- .../examples/online_serving/aura_omni.md | 67 +- examples/online_serving/aura_omni/README.md | 176 ++--- .../aura_omni/aura_omni_duplex_smoke.yaml | 119 +++ .../aura_omni/realtime_web/__init__.py | 4 + .../aura_omni/realtime_web/__main__.py | 7 + .../aura_omni/run_duplex_smoke_serve.sh | 29 + .../aura_omni/smoke_duplex_realtime_client.py | 153 ++++ .../online_serving/realtime_web/README.md | 38 +- .../realtime_web/app/index.html | 2 + .../realtime_web/app/static/app.js | 219 +++++- .../app/static/profiles/aura_ptt.js | 79 ++ .../realtime_web/app/static/styles.css | 12 + .../online_serving/realtime_web/server.py | 20 +- .../test_aura_omniinteract_clock.py | 157 ++++ tests/config/test_environment_variables.py | 4 +- .../duplex/test_aura_resumable_hooks.py | 725 ++++++++++++++++++ tests/engine/duplex/test_engine_session.py | 105 +++ tests/engine/duplex/test_input_transcribed.py | 49 ++ tests/engine/duplex/test_overlap_policy.py | 33 + tests/engine/duplex/test_session_manager.py | 43 +- tests/engine/duplex/test_session_runner.py | 259 ++++++- .../test_session_runner_ephemeral_bind.py | 28 + tests/engine/test_duplex_orchestrator.py | 94 ++- .../test_orchestrator_stage_input_bridge.py | 36 + .../entrypoints/duplex/test_startup_warmup.py | 56 ++ .../test_minicpmo_realtime_web_server.py | 13 +- .../test_minicpmo_realtime_web_static.py | 15 + tests/examples/test_realtime_web_profiles.cjs | 157 +++- .../aura_omni/duplex/test_aura_data_plane.py | 165 ++++ .../duplex/test_aura_data_plane_multimodal.py | 115 +++ .../duplex/test_aura_plugin_contract.py | 598 +++++++++++++++ .../aura_omni/duplex/test_sentence_decode.py | 62 ++ .../test_qwen3_tts_talker_preprocess.py | 32 + .../stage_input_processors/test_aura_omni.py | 113 ++- .../test_aura_omni_duplex_history.py | 273 +++++++ .../test_realtime_codec_single_source.py | 5 +- vllm_omni/benchmarks/omniinteract.py | 70 +- .../config/environment_variable_inventory.py | 2 + vllm_omni/config/stage_config.py | 10 +- vllm_omni/deploy/aura_omni.yaml | 17 +- vllm_omni/engine/duplex/commands.py | 7 +- vllm_omni/engine/duplex/config.py | 33 + vllm_omni/engine/duplex/contracts.py | 81 +- vllm_omni/engine/duplex/pcm_reservation.py | 53 ++ vllm_omni/engine/duplex/plugin.py | 112 +++ vllm_omni/engine/duplex/realtime_events.py | 41 +- .../engine/duplex/session/append_task.py | 13 +- vllm_omni/engine/duplex/session/context.py | 4 + .../engine/duplex/session/engine_session.py | 204 ++++- vllm_omni/engine/duplex/session/helpers.py | 28 +- vllm_omni/engine/duplex/session/manager.py | 17 +- .../engine/duplex/session/model_channel.py | 270 ++++++- .../engine/duplex/session/overlap_policy.py | 15 + vllm_omni/engine/duplex/session/runner.py | 357 ++++++--- vllm_omni/engine/duplex_orchestrator.py | 98 ++- vllm_omni/engine/orchestrator.py | 4 + vllm_omni/entrypoints/duplex/warmup.py | 202 ++++- vllm_omni/entrypoints/openai/api_server.py | 27 +- .../models/aura_omni/benchmarks/__init__.py | 4 + .../benchmarks/omniinteract_clock.py | 287 +++++++ .../models/aura_omni/duplex/__init__.py | 6 + .../models/aura_omni/duplex/capabilities.py | 48 ++ .../models/aura_omni/duplex/data_plane.py | 381 +++++++++ .../models/aura_omni/duplex/history.py | 128 ++++ .../models/aura_omni/duplex/input.py | 186 +++++ .../models/aura_omni/duplex/plugin.py | 520 +++++++++++++ .../models/aura_omni/duplex/sentence_tts.py | 189 +++++ .../models/aura_omni/duplex/session.py | 58 ++ .../models/aura_omni/pipeline.py | 1 + .../minicpmo_4_5/duplex/capabilities.py | 3 + .../models/minicpmo_4_5/duplex/input.py | 30 +- .../models/qwen3_tts/qwen3_tts_talker.py | 15 +- .../stage_input_processors/aura_omni.py | 515 ++++++++++++- vllm_omni/protocol/duplex/commands.py | 4 +- vllm_omni/protocol/duplex/errors.py | 1 + vllm_omni/protocol/realtime/audio_input.py | 46 +- vllm_omni/protocol/realtime/commands.py | 3 +- 77 files changed, 7695 insertions(+), 457 deletions(-) create mode 100644 examples/online_serving/aura_omni/aura_omni_duplex_smoke.yaml create mode 100644 examples/online_serving/aura_omni/realtime_web/__init__.py create mode 100644 examples/online_serving/aura_omni/realtime_web/__main__.py create mode 100755 examples/online_serving/aura_omni/run_duplex_smoke_serve.sh create mode 100755 examples/online_serving/aura_omni/smoke_duplex_realtime_client.py create mode 100644 examples/online_serving/realtime_web/app/static/profiles/aura_ptt.js create mode 100644 tests/benchmarks/test_aura_omniinteract_clock.py create mode 100644 tests/engine/duplex/test_aura_resumable_hooks.py create mode 100644 tests/engine/duplex/test_input_transcribed.py create mode 100644 tests/engine/duplex/test_session_runner_ephemeral_bind.py create mode 100644 tests/entrypoints/duplex/test_startup_warmup.py create mode 100644 tests/model_executor/models/aura_omni/duplex/test_aura_data_plane.py create mode 100644 tests/model_executor/models/aura_omni/duplex/test_aura_data_plane_multimodal.py create mode 100644 tests/model_executor/models/aura_omni/duplex/test_aura_plugin_contract.py create mode 100644 tests/model_executor/models/aura_omni/duplex/test_sentence_decode.py create mode 100644 tests/model_executor/stage_input_processors/test_aura_omni_duplex_history.py create mode 100644 vllm_omni/engine/duplex/pcm_reservation.py create mode 100644 vllm_omni/model_executor/models/aura_omni/benchmarks/__init__.py create mode 100644 vllm_omni/model_executor/models/aura_omni/benchmarks/omniinteract_clock.py create mode 100644 vllm_omni/model_executor/models/aura_omni/duplex/__init__.py create mode 100644 vllm_omni/model_executor/models/aura_omni/duplex/capabilities.py create mode 100644 vllm_omni/model_executor/models/aura_omni/duplex/data_plane.py create mode 100644 vllm_omni/model_executor/models/aura_omni/duplex/history.py create mode 100644 vllm_omni/model_executor/models/aura_omni/duplex/input.py create mode 100644 vllm_omni/model_executor/models/aura_omni/duplex/plugin.py create mode 100644 vllm_omni/model_executor/models/aura_omni/duplex/sentence_tts.py create mode 100644 vllm_omni/model_executor/models/aura_omni/duplex/session.py diff --git a/docs/user_guide/examples/online_serving/aura_omni.md b/docs/user_guide/examples/online_serving/aura_omni.md index 8b7f56ac5..2042d0d37 100644 --- a/docs/user_guide/examples/online_serving/aura_omni.md +++ b/docs/user_guide/examples/online_serving/aura_omni.md @@ -6,29 +6,29 @@ ASR -> AURA -> Qwen3-TTS Talker -> Code2Wav ``` -Qwen3-TTS remains two engine stages so the pipeline reuses the existing native -Talker and Code2Wav implementation. +**Supported online path:** Realtime duplex at `/v1/realtime?duplex=1`. + +The default deploy profile (`vllm_omni/deploy/aura_omni.yaml`) sets +`session_mode: duplex`. The `aura_omni` pipeline declares `duplex_plugin`, and +`DuplexOmniEngine` requires that mode (same pattern as MiniCPM-o duplex +deploys). Turn-based `chat/completions` is not a supported AURA online mode +against this profile. + +`async_chunk` defaults to `false` in that file (safer if a turn-based +`OmniOrchestrator` ever loads it). Duplex Realtime Stage2→3 still needs +async chunks — use the smoke deploy below, or set `async_chunk: true` +yourself. ```bash -vllm serve aurateam/AURA \ - --omni \ - --deploy-config vllm_omni/deploy/aura_omni.yaml \ - --served-model-name aurateam/AURA \ - --trust-remote-code +bash examples/online_serving/aura_omni/run_duplex_smoke_serve.sh +python examples/online_serving/aura_omni/smoke_duplex_realtime_client.py ``` -Configure local checkpoints by editing per-stage `model` values in -`vllm_omni/deploy/aura_omni.yaml`. The deploy file sets -`pipeline: aura_omni`, so the four-stage topology is used even if the -command-line `--model` points at one of the component checkpoints. +Configure local checkpoints by editing per-stage `model` values in the deploy +or smoke YAML. The file sets `pipeline: aura_omni`, so the four-stage topology +is used even if the command-line `--model` points at one component checkpoint. -Send requests with `"model": "aurateam/AURA"`. The ASR, AURA, and Qwen3-TTS -checkpoint paths are internal stage models from the deploy YAML, not the -OpenAI-facing served model name. - -The AURA stage can emit `<|silent|>`. Silent outputs are treated as a gate: -they produce no Qwen3-TTS Talker input, so no audio is synthesized for that -turn. +Silent Stage1 outputs (`<|silent|>` / id `151669`) skip TTS for that turn. ## GPU Utilization Recommendation @@ -40,26 +40,21 @@ VRAM each stage can reserve. Start with this split for a single GPU: - Stage 2 (Qwen3-TTS Talker): `0.20` - Stage 3 (Qwen3-TTS Code2Wav): `0.20` -## TTS Modes +## TTS modes (stage extras) -`aura_omni` can pass AURA text to Qwen3-TTS in two task modes: +When the duplex session supplies TTS extras, AURA text can feed Qwen3-TTS as: -- `Base`: voice clone from `tts_ref_audio` with ICL enabled in the AURA - pipeline. Provide both `tts_ref_audio` and `tts_ref_text`. Set - `tts_x_vector_only_mode=true` to disable ICL and use speaker embedding only. -- `CustomVoice`: predefined speaker mode. Use a Qwen3-TTS CustomVoice - checkpoint for stages 2 and 3 in `aura_omni.yaml`, then pass - `tts_task_type=CustomVoice` and `tts_speaker`. +- `Base`: voice clone from `tts_ref_audio` (optional x-vector-only mode) +- `CustomVoice`: predefined speaker (`tts_speaker`) with a CustomVoice checkpoint + on stages 2 and 3 -By default, AURA responses are passed to Qwen3-TTS as text. Set -`additional_information.tts_pass_token_ids=true` to pass AURA-generated -assistant token ids directly instead. Even when token passthrough is disabled, -the stage processor uses AURA token ids when available to estimate the Talker -prefill length, so scheduling does not rely on raw character length. +Optional `tts_pass_token_ids` passes AURA assistant token ids into Talker +instead of detokenized text. -The example client exposes this as: +## Old chat / Gradio / curl scripts (unsupported) -```bash -python examples/online_serving/aura_omni/openai_chat_completion_client.py \ - --tts-pass-token-ids -``` +`examples/online_serving/aura_omni/` still contains chat-completions, curl, and +Gradio helpers from an older turn-based path. They do **not** work against the +shipped duplex profile. That path was an incomplete stand-in before streaming +I/O and session history were available; it is not a supported AURA mode. Use +the Realtime duplex smoke client for online verification. diff --git a/examples/online_serving/aura_omni/README.md b/examples/online_serving/aura_omni/README.md index 5f246c336..903850469 100644 --- a/examples/online_serving/aura_omni/README.md +++ b/examples/online_serving/aura_omni/README.md @@ -6,139 +6,89 @@ Qwen3-ASR -> AURA/Qwen3-VL -> Qwen3-TTS Talker -> Qwen3-TTS Code2Wav ``` -The pipeline has three semantic modules, but four engine stages because the -existing Qwen3-TTS implementation is natively split into Talker and Code2Wav. +**AURA's supported online path is Realtime duplex** (`/v1/realtime?duplex=1`). +The default deploy profile sets `session_mode: duplex` because the pipeline +declares `duplex_plugin` and `DuplexOmniEngine` requires that mode. -Start the server with the deploy profile: +This is AURA v1 / Qwen3-VL (silent / ChatML ids `151669` / `151645`), not +AURA v2 / Qwen3.5-VL. + +## Duplex Realtime (only supported online path) + +Stage2→3 codec handoff needs `async_chunk: true`. The default +`vllm_omni/deploy/aura_omni.yaml` keeps `async_chunk: false` as a safer +baseline (turn-based `OmniOrchestrator` never sees the duplex async-chunk +gate). For Realtime, use the smoke deploy (already `async_chunk: true`) or +set that flag yourself: ```bash +bash examples/online_serving/aura_omni/run_duplex_smoke_serve.sh +python examples/online_serving/aura_omni/smoke_duplex_realtime_client.py +``` + +Smoke deploy file: `examples/online_serving/aura_omni/aura_omni_duplex_smoke.yaml`. + +Equivalent serve with the default topology and async chunks enabled: + +```bash +# Copy or edit so async_chunk: true, then: vllm serve aurateam/AURA \ --omni \ --port 8091 \ - --deploy-config vllm_omni/deploy/aura_omni.yaml \ + --deploy-config examples/online_serving/aura_omni/aura_omni_duplex_smoke.yaml \ --served-model-name aurateam/AURA \ --trust-remote-code ``` -The deploy file sets per-stage model repos: +Connect clients to `/v1/realtime?duplex=1`. Silent Stage1 outputs gate TTS (no +audio for that turn). Overlapped input and vision-follow are AURA duplex +capabilities. + +### Browser UI + +AURA has no client VAD: control is **push-to-talk** on the shared shell from +[#7585](https://github.com/vllm-project/vllm-omni/pull/7585) +([`examples/online_serving/realtime_web/`](../realtime_web/README.md)), profile +`aura-ptt` (hold = `is_speech=true` PCM + sticky frames; release = `commit`; +silent+frames for vision-follow). Thin wrapper: + +```bash +python -m examples.online_serving.aura_omni.realtime_web \ + --ws-backend ws://127.0.0.1:8099 --model aurateam/AURA --port 7862 +``` + +Do **not** fork a separate `aura_omni/realtime_web` asset tree; MiniCPM / Qwen +profiles are unchanged and hide the PTT control. + +### Per-stage models + +Edit `model` on each stage in `vllm_omni/deploy/aura_omni.yaml` (or the smoke +YAML) for local checkpoints: - Stage 0 ASR: `Qwen/Qwen3-ASR-1.7B` - Stage 1 AURA: `aurateam/AURA` -- Stage 2/3 TTS: `Qwen/Qwen3-TTS-12Hz-1.7B-Base` +- Stage 2/3 TTS: `Qwen/Qwen3-TTS-12Hz-1.7B-Base` (or CustomVoice) -For local weights, edit the `model` value on each stage in -`vllm_omni/deploy/aura_omni.yaml`. The deploy profile includes -`pipeline: aura_omni`, so the server uses this four-stage topology even when -the command-line model path points at one component checkpoint. +### GPU utilization -Expected request shape: - -- Send microphone audio as the Stage 0 multimodal audio input. -- Include video frames in the original request `multi_modal_data`; the - `asr2aura` processor carries them forward to AURA. -- Optional `additional_information` keys: - - `aura_system_prompt` - - `tts_task_type` - - `tts_language` - - `tts_speaker` - - `tts_instruct` - - `tts_ref_audio` - - `tts_ref_text` - - `tts_x_vector_only_mode` - - `tts_pass_token_ids` - -If AURA emits `<|silent|>`, the `aura2tts` processor returns no TTS request, so -the TTS stages are skipped for that turn. - -## GPU Utilization Recommendation - -Tune `gpu_memory_utilization` per stage in `vllm_omni/deploy/aura_omni.yaml`. -Recommended baseline on one GPU for H200 +Tune `gpu_memory_utilization` per stage. Baseline on one large GPU: - Stage 0 (ASR): `0.10` -- Stage 1 (AURA): `0.4` -- Stage 2 (Qwen3-TTS Talker): `0.20` -- Stage 3 (Qwen3-TTS Code2Wav): `0.20` +- Stage 1 (AURA): `0.40` +- Stage 2 (Talker): `0.20` +- Stage 3 (Code2Wav): `0.20` -## Python Client +## Old chat / Gradio / curl scripts (unsupported) -```bash -python examples/online_serving/aura_omni/openai_chat_completion_client.py \ - --host localhost \ - --port 8091 \ - --model aurateam/AURA \ - --modalities text,audio -``` +`openai_chat_completion_client.py`, `run_curl_multimodal_generation.sh`, and +`run_gradio_demo.sh` target the older turn-based Omni path. They do **not** +work against the shipped `session_mode: duplex` profile. -Use local media: - -```bash -python examples/online_serving/aura_omni/openai_chat_completion_client.py \ - --audio-path /path/to/input.wav \ - --video-path /path/to/video.mp4 \ - --output-dir output_aura_omni_online -``` - -Base voice clone mode (default, recommended as x-vector while debugging ICL): - -```bash -python examples/online_serving/aura_omni/openai_chat_completion_client.py \ - --tts-task-type Base \ - --tts-ref-audio vllm-omni/tests/assets/qwen3_tts/clone_2.wav \ - --tts-ref-text "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you." -``` - -Enable AURA token-id passthrough explicitly: - -```bash -python examples/online_serving/aura_omni/openai_chat_completion_client.py \ - --tts-pass-token-ids -``` - -CustomVoice mode requires stages 2 and 3 in `aura_omni.yaml` to point at a -Qwen3-TTS CustomVoice checkpoint: - -```bash -python examples/online_serving/aura_omni/openai_chat_completion_client.py \ - --tts-task-type CustomVoice \ - --tts-speaker Vivian -``` - -By default, AURA responses are passed to Qwen3-TTS as text. Set -`tts_pass_token_ids=true` to pass AURA-generated assistant token ids directly -to Qwen3-TTS instead. The processor still uses AURA token ids, when available, -to estimate the Talker prompt length in the default text path. - -## Curl - -```bash -cd examples/online_serving/aura_omni -bash run_curl_multimodal_generation.sh -``` - -Set `PORT`, `MODEL`, or `OUTPUT_DIR` to override defaults: - -```bash -PORT=8666 MODEL=aurateam/AURA bash run_curl_multimodal_generation.sh -TTS_PASS_TOKEN_IDS=true PORT=8666 MODEL=aurateam/AURA bash run_curl_multimodal_generation.sh -``` - -## Gradio - -Launch the server and Gradio UI together: - -```bash -cd examples/online_serving/aura_omni -bash run_gradio_demo.sh -``` - -If the server is already running: - -```bash -python examples/online_serving/aura_omni/gradio_demo.py \ - --model aurateam/AURA \ - --api-base http://localhost:8091/v1 -``` +That turn-based path was an incomplete June-era stand-in: vLLM-Omni did not +yet support streaming I/O for this stack, and adding session history then would +have required a much larger orchestrator change. It is not a supported AURA +mode. Do not run those scripts against the default deploy. Prefer the duplex +smoke client above. ## Offline diff --git a/examples/online_serving/aura_omni/aura_omni_duplex_smoke.yaml b/examples/online_serving/aura_omni/aura_omni_duplex_smoke.yaml new file mode 100644 index 000000000..2be7ea0e7 --- /dev/null +++ b/examples/online_serving/aura_omni/aura_omni_duplex_smoke.yaml @@ -0,0 +1,119 @@ +# AURA duplex Realtime smoke (local weights, session_mode: duplex). +# Stage1 = /workspace/models/AURA + AuraQwen3VLForConditionalGeneration (AURA v1 / Qwen3-VL). +pipeline: aura_omni +# Qwen3-TTS Stage2→3 codec handoff requires async_chunk=true (same as native / +# duplex explore). async_chunk=false reaches talker/code_predictor then never +# delivers audio to Stage3/client. +async_chunk: true +session_mode: duplex +duplex_session: + max_sessions: 1 + +connectors: + connector_of_shared_memory: + name: SharedMemoryConnector + extra: + codec_streaming: true + connector_get_sleep_s: 0.01 + connector_get_max_wait_first_chunk: 3000 + connector_get_max_wait: 300 + codec_chunk_frames: 25 + codec_left_context_frames: 72 + # Match native/duplex explore. initial=1 can stall Code2Wav on the first + # undersized chunk (observed hang after "Code2Wav codec: frames=1"). + initial_codec_chunk_frames: 8 + decode_cudagraph_capture_sizes: [25, 73, 97, 169, 325] + +stages: + - stage_id: 0 + max_num_seqs: 1 + gpu_memory_utilization: 0.08 + enforce_eager: true + trust_remote_code: true + enable_prefix_caching: true + async_scheduling: true + max_model_len: 4096 + max_num_batched_tokens: 2048 + devices: "0" + model: /workspace/models/Qwen3-ASR-1.7B + default_sampling_params: + temperature: 0.0 + top_p: 1.0 + top_k: -1 + max_tokens: 256 + + - stage_id: 1 + max_num_seqs: 1 + gpu_memory_utilization: 0.55 + enforce_eager: true + trust_remote_code: true + enable_prefix_caching: true + async_scheduling: true + max_model_len: 32768 + max_num_batched_tokens: 8192 + devices: "0" + model: /workspace/models/AURA + model_arch: AuraQwen3VLForConditionalGeneration + default_sampling_params: + temperature: 0.0 + top_p: 1.0 + top_k: -1 + max_tokens: 256 + repetition_penalty: 1.0 + stop_token_ids: [151669, 151645] + + - stage_id: 2 + # Allow prior TTS to drain while the next Talker starts (not assumed 1). + max_num_seqs: 2 + gpu_memory_utilization: 0.12 + enforce_eager: true + trust_remote_code: true + enable_prefix_caching: false + async_scheduling: true + max_num_batched_tokens: 4096 + max_model_len: 4096 + devices: "0" + # Stage1→2 is orchestrator-fed via aura2tts (custom_process_input_func). + # A from_stage_1 SHM edge parks Talker in WAITING_FOR_CHUNK and clears + # additional_information.text → Missing Qwen3-TTS text conditioning. + # Only Stage2→3 codec handoff needs a connector. + output_connectors: + to_stage_3: connector_of_shared_memory + model: /workspace/models/Qwen3-TTS-12Hz-1.7B-CustomVoice + default_sampling_params: + temperature: 0.5 + top_k: 50 + # 12Hz × 240 ≈ 20s. Stay under the smoke 25s audio gate so a missed + # 2150 (English/quotes humming) cannot overlap the next vision-follow. + max_tokens: 240 + min_tokens: 8 + repetition_penalty: 1.05 + # Qwen3-TTS Talker codec EOS — without this Talker can run to max_tokens + # (~26s garbage audio while Stage1 text was correct). + stop_token_ids: [2150] + subtalker_sampling_params: + do_sample: true + temperature: 0.5 + top_k: 50 + top_p: 1.0 + + - stage_id: 3 + max_num_seqs: 2 + gpu_memory_utilization: 0.12 + enforce_eager: true + trust_remote_code: true + enable_prefix_caching: false + async_scheduling: true + max_num_batched_tokens: 16384 + max_model_len: 16384 + devices: "0" + input_connectors: + from_stage_2: connector_of_shared_memory + model: /workspace/models/Qwen3-TTS-12Hz-1.7B-CustomVoice + default_sampling_params: + temperature: 0.0 + top_p: 1.0 + top_k: -1 + max_tokens: 16384 + seed: 42 + repetition_penalty: 1.0 diff --git a/examples/online_serving/aura_omni/realtime_web/__init__.py b/examples/online_serving/aura_omni/realtime_web/__init__.py new file mode 100644 index 000000000..d305da15b --- /dev/null +++ b/examples/online_serving/aura_omni/realtime_web/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""AURA push-to-talk browser call UI.""" diff --git a/examples/online_serving/aura_omni/realtime_web/__main__.py b/examples/online_serving/aura_omni/realtime_web/__main__.py new file mode 100644 index 000000000..1f89c5d41 --- /dev/null +++ b/examples/online_serving/aura_omni/realtime_web/__main__.py @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from examples.online_serving.realtime_web.server import main + +if __name__ == "__main__": + main(default_profile="aura-ptt") diff --git a/examples/online_serving/aura_omni/run_duplex_smoke_serve.sh b/examples/online_serving/aura_omni/run_duplex_smoke_serve.sh new file mode 100755 index 000000000..ed8f3dc40 --- /dev/null +++ b/examples/online_serving/aura_omni/run_duplex_smoke_serve.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Minimal AURA duplex Realtime server for smoke. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$REPO_ROOT" + +PORT="${PORT:-8099}" +MODEL="${MODEL:-/workspace/models/AURA}" +DEPLOY_CONFIG="${DEPLOY_CONFIG:-$REPO_ROOT/examples/online_serving/aura_omni/aura_omni_duplex_smoke.yaml}" + +# AURA v1 / Qwen3-VL ChatML ids (do not use AURA v2 248070/248046 here). +export VLLM_AURA_SILENT_TOKEN_ID="${VLLM_AURA_SILENT_TOKEN_ID:-151669}" +export VLLM_AURA_IM_END_TOKEN_ID="${VLLM_AURA_IM_END_TOKEN_ID:-151645}" +export VLLM_AURA_IM_START_TOKEN_ID="${VLLM_AURA_IM_START_TOKEN_ID:-151644}" +export VLLM_AURA_ASSISTANT_TOKEN_ID="${VLLM_AURA_ASSISTANT_TOKEN_ID:-77091}" +# Multi-stage colocated init can make earlier stages' Process.sentinel spuriously +# readable; prefer spawn for stage engine subprocesses (override if needed). +export VLLM_WORKER_MULTIPROC_METHOD="${VLLM_WORKER_MULTIPROC_METHOD:-spawn}" +# Do NOT set TORCHDYNAMO_DISABLE here: it breaks Stage0/1 AOT when those +# stages are not enforce_eager. Stage2/3 use enforce_eager; code_predictor +# honors that and skips its own torch.compile. + +exec "$REPO_ROOT/.venv/bin/vllm-omni" serve "$MODEL" \ + --omni \ + --deploy-config "$DEPLOY_CONFIG" \ + --trust-remote-code \ + --host 0.0.0.0 \ + --port "$PORT" diff --git a/examples/online_serving/aura_omni/smoke_duplex_realtime_client.py b/examples/online_serving/aura_omni/smoke_duplex_realtime_client.py new file mode 100755 index 000000000..431e13d90 --- /dev/null +++ b/examples/online_serving/aura_omni/smoke_duplex_realtime_client.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Minimal AURA duplex Realtime smoke via DuplexClient.""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import sys +import time +from io import BytesIO +from pathlib import Path + +from PIL import Image + +REPO_ROOT = Path(__file__).resolve().parents[3] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from vllm_omni.clients.duplex import ( # noqa: E402 + DuplexClient, + EventCollector, + SessionConfig, + read_pcm16_wav, +) + +PROGRESS_TYPES = { + "response.created", + "response.done", + "response.audio.delta", + "response.output_audio.delta", + "response.audio_transcript.delta", + "response.output_text.delta", + "response.text.delta", + "response.listen", + "response.model_listen", + "error", +} + + +def _tiny_jpeg_b64() -> str: + img = Image.new("RGB", (64, 64), color=(200, 40, 40)) + buf = BytesIO() + img.save(buf, format="JPEG", quality=85) + return base64.b64encode(buf.getvalue()).decode("ascii") + + +async def run(url: str, model: str, wav: Path, timeout_s: float) -> int: + pcm16 = read_pcm16_wav(wav) + if not pcm16: + print("empty wav", file=sys.stderr) + return 2 + + frame = _tiny_jpeg_b64() + collector = EventCollector() + config = SessionConfig( + modalities=("text", "audio"), + instructions="You are AURA. Reply briefly in Chinese.", + extra_body={ + "aura_system_prompt": "You are AURA. Reply briefly in Chinese.", + "tts_task_type": "CustomVoice", + "tts_language": "Chinese", + "tts_speaker": "Vivian", + }, + ) + + started = time.time() + client = DuplexClient( + url, + model=model, + config=config, + reconnect=None, + heartbeat_interval_s=None, + handshake_timeout_s=min(60.0, timeout_s), + ) + + async with client: + consume_task = asyncio.create_task(collector.consume(client)) + collector.add({"type": "session.created", "session": getattr(client, "session_info", {})}) + + await client.append_audio(pcm16, is_speech=True, video_frames=[frame]) + await client.commit(create_response=True) + + progress = False + got_done = False + while time.time() - started < timeout_s: + types = [e.get("type") for e in collector.events] + if any(t in PROGRESS_TYPES for t in types): + progress = True + if "response.done" in types or "response.completed" in types: + got_done = True + progress = True + break + for e in collector.events: + meta = e.get("metadata") if isinstance(e.get("metadata"), dict) else {} + if meta.get("model_listen") or meta.get("listen_source") == "aura_silent": + progress = True + break + if collector.count("error") > 0 or collector.count("session.closed") > 0: + break + # Keep draining until response.done so TTS is not truncated. + await asyncio.sleep(0.2) + + try: + await client.close(timeout_s=30.0) + except Exception as exc: # noqa: BLE001 + print(f"close_warn={exc}", file=sys.stderr) + try: + await asyncio.wait_for(consume_task, timeout=5.0) + except (TimeoutError, asyncio.CancelledError): + consume_task.cancel() + + event_types = [e.get("type") for e in collector.events] + errors = [json.dumps(e, ensure_ascii=False)[:500] for e in collector.errors()] + result = { + "ok": progress and (got_done or collector.count("response.done") > 0), + "got_response_progress": progress, + "got_response_done": got_done or collector.count("response.done") > 0, + "errors": errors, + "event_types": event_types, + "n_events": len(event_types), + "audio_bytes": len(collector.audio_bytes()), + "llm_text": collector.response_text(collector.response_ids[0]) if collector.response_ids else "", + "elapsed_s": round(time.time() - started, 2), + } + print(json.dumps(result, ensure_ascii=False, indent=2)) + log_path = Path("/tmp/aura_duplex_smoke/client_events.jsonl") + log_path.parent.mkdir(parents=True, exist_ok=True) + log_path.write_text( + "".join(json.dumps(e, ensure_ascii=False) + "\n" for e in collector.events), + encoding="utf-8", + ) + print(f"events_log={log_path}", file=sys.stderr) + return 0 if progress else 1 + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--url", default="ws://127.0.0.1:8099/v1/realtime") + p.add_argument("--model", default="/workspace/models/AURA") + p.add_argument( + "--wav", + default=str(REPO_ROOT / "tests/assets/minicpmo_4_5/response_required_16k.wav"), + ) + p.add_argument("--timeout-s", type=float, default=300.0) + args = p.parse_args() + return asyncio.run(run(args.url, args.model, Path(args.wav), args.timeout_s)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/online_serving/realtime_web/README.md b/examples/online_serving/realtime_web/README.md index 854aa4718..7aba2ee10 100644 --- a/examples/online_serving/realtime_web/README.md +++ b/examples/online_serving/realtime_web/README.md @@ -9,6 +9,7 @@ This implements [RFC #7222](https://github.com/vllm-project/vllm-omni/issues/722 | `minicpm-native` | Model-controlled listen/speak, continuous audio input | Frames accompany audio | Yes | | `qwen3-turn --stt` (default) | User presses **Send turn** | No | No | | `qwen3-turn --vad` | Server detects trailing silence; speech interrupts replies | Sampled frames with each spoken turn | Yes | +| `aura-ptt` | Hold **Hold to talk**; release commits. Hold also stops local playback | Sticky frames with speech; vision-follow every 2 frames only while unlocked (locked from PTT release until text-final / listen) | Yes | Qwen3 VAD uses an engine-owned duplex plugin: microphone upload continues while replies stream, and speech can interrupt generation and playback. The @@ -29,6 +30,38 @@ The compatibility wrapper serves the shared assets with `minicpm-native`. The current native query, `extra_body`, reference voice, continuous microphone upload, camera frames and playback acknowledgements are retained. +## AURA: push-to-talk duplex + +AURA has no client VAD. Start the duplex serve, then the thin wrapper: + +```bash +bash examples/online_serving/aura_omni/run_duplex_smoke_serve.sh + +python -m examples.online_serving.aura_omni.realtime_web \ + --ws-backend ws://127.0.0.1:8099 --model aurateam/AURA --port 7862 +``` + +Or select the profile on the shared host: + +```bash +python -m examples.online_serving.realtime_web --profile aura-ptt \ + --ws-backend ws://127.0.0.1:8099 --model aurateam/AURA --port 7862 +``` + +Open the UI, start a session, optionally enable **Camera**, then **hold** +**Hold to talk** to stream `is_speech=true` PCM (with sticky camera frames). +**Release** to `input_audio_buffer.commit`. Holding the button stops playback +in the browser only; the in-flight response is not cancelled on the server. +With the camera on, frames are still captured at 2 fps. While the button is +up they are committed once per two frames (`is_speech=false` + silent PCM). +Both frames of that commit are sent and packed as one video (`<|video_pad|>`). +Turns still open from this client commit, not from a server-side auto trigger. +but only after the prior spoken turn unlocks (text-final or `response.listen`). +Release itself locks vision immediately so a follow-up frame cannot abort the +just-committed speech turn on Stage0/1 (`max_num_seqs=1`). Audio still playing +does not hold the next vision turn once text is done. +MiniCPM / Qwen profiles do not set `pushToTalk`, so the PTT control stays hidden. + ## Qwen3: explicit-turn STT adapter Start the backend, then the UI in a second terminal: @@ -49,6 +82,7 @@ accepts only model selection, so system-prompt controls are hidden in this mode. The adapter follows the shipped `qwen3_omni/openai_realtime_client.py` and `vllm_omni/entrypoints/openai/realtime_connection.py`: + 1. Explicit `duplex=0` selects the legacy STT handler on a turn deployment. It does not enable STT on a duplex deployment. 2. Send `{type: "session.update", model: ...}` and `commit(final=false)`. @@ -181,7 +215,7 @@ conversation API. This demo does not add WebRTC, semantic VAD, or tool calling. ## Shared host options -The shared entry point can select either profile explicitly: +The shared entry point can select a profile explicitly: ```bash python -m examples.online_serving.realtime_web --profile qwen3-turn \ @@ -193,7 +227,7 @@ python -m examples.online_serving.realtime_web --profile qwen3-turn \ - `--public-realtime-url`: optional browser-visible WebSocket URL; otherwise the static host proxies `/v1/realtime` on the same origin. - `--host`, `--port`: UI bind address and port (default port 7862). -- `--ref-audio`: required for MiniCPM; rejected for Qwen3. +- `--ref-audio`: required for MiniCPM; rejected for Qwen3 and AURA. Microphone and camera access require `localhost` or HTTPS. For a remote backend, use an SSH tunnel to the UI host or an HTTPS reverse proxy with WebSocket support. diff --git a/examples/online_serving/realtime_web/app/index.html b/examples/online_serving/realtime_web/app/index.html index a38a731e4..d4662d5a1 100644 --- a/examples/online_serving/realtime_web/app/index.html +++ b/examples/online_serving/realtime_web/app/index.html @@ -22,6 +22,7 @@

+ @@ -75,6 +76,7 @@ + diff --git a/examples/online_serving/realtime_web/app/static/app.js b/examples/online_serving/realtime_web/app/static/app.js index 1dd01d4b3..f57a3f064 100644 --- a/examples/online_serving/realtime_web/app/static/app.js +++ b/examples/online_serving/realtime_web/app/static/app.js @@ -5,6 +5,7 @@ const profile = window.OmniRealtimeProfiles[config.profile || 'minicpm-native'](config); const callButton = document.getElementById('callButton'); const sendTurnButton = document.getElementById('sendTurnButton'); + const pttButton = document.getElementById('pttButton'); const muteButton = document.getElementById('muteButton'); const cameraButton = document.getElementById('cameraButton'); const cameraPreview = document.getElementById('cameraPreview'); @@ -28,6 +29,7 @@ const ECHO_GUARD_MS = 300; const INITIAL_PLAYBACK_BUFFER_MS = 400; const SESSION_CLOSE_TIMEOUT_MS = 1000; + const SILENT_PCM_SAMPLES = 160; const PROMPT_PRESETS = profile.presets; document.title = profile.title; @@ -36,7 +38,21 @@ document.getElementById('profileDescription').textContent = profile.description; document.getElementById('policyLabel').textContent = profile.policy; cameraButton.hidden = !profile.camera; + if (profile.cameraPreviewLarge) { + cameraPreview.classList.add('camera-preview-large'); + // Inline, so a cached stylesheet cannot leave the 96×72 HTML default. + cameraPreview.style.width = '100%'; + cameraPreview.style.height = 'auto'; + cameraPreview.style.maxHeight = '480px'; + cameraPreview.style.flex = '0 0 100%'; + const cameraActions = cameraPreview.closest('.call-actions'); + if (cameraActions) { + cameraActions.style.flexWrap = 'wrap'; + cameraActions.style.gridColumn = '1 / -1'; + } + } sendTurnButton.hidden = !profile.clientCommit; + pttButton.hidden = !profile.pushToTalk; promptPreset.replaceChildren(); for (const name of [...Object.keys(PROMPT_PRESETS), 'custom']) { const option = document.createElement('option'); @@ -64,6 +80,12 @@ let cameraStream = null; let cameraTimer = null; let cameraPendingFrame = null; + let cameraLastFrame = null; + // AURA vision clock only. Other profiles leave this empty and never read it. + let visionFollowQueue = []; + let visionTurnLocked = false; + let suppressedPlaybackId = null; + let pttHeld = false; const cameraCanvas = document.createElement('canvas'); let playbackRate = OUTPUT_RATE; let pendingCapture = []; @@ -87,6 +109,8 @@ const interruptedResponses = new Set(); const pendingEvents = new Set(); let assistantTextChannel = null; + const assistantRowByResponse = new Map(); + let lastClosedAssistantText = ''; let connectionReady = false; let turnCounter = 0; let stopping = null; @@ -134,7 +158,7 @@ assistantTextChannel = null; if (profile.halfDuplex) pendingCapture = []; sendTurnButton.disabled = !profile.clientCommit; - setModel(profile.waiting); + if (!pttHeld) setModel(profile.waiting); }, ECHO_GUARD_MS); } @@ -240,6 +264,7 @@ current.value = finalText; current.text.textContent = finalText; } + if (role === 'assistant' && current.value) lastClosedAssistantText = current.value; current.row.classList.remove('turn-live'); if (role === 'user') liveUserTurn = null; else liveAssistantTurn = null; @@ -317,11 +342,67 @@ } function microphoneUploadEnabled() { - return running && connectionReady && !muted && (!profile.halfDuplex || !assistantActive); + if (!running || !connectionReady || muted) return false; + if (profile.pushToTalk) return pttHeld; + return !profile.halfDuplex || !assistantActive; + } + + function queueVisionFrame(frame) { + visionFollowQueue.push(frame); + if (visionFollowQueue.length > 2) visionFollowQueue.shift(); + } + + function commitVisionFollow() { + // Two frames still gate the turn (client commit). Both frames are one clip; + // the server packs them as a single <|video_pad|>, same as Native. + const frames = visionFollowQueue.slice(); + visionFollowQueue = []; + const silent = new Int16Array(SILENT_PCM_SAMPLES); + const event = profile.append(int16ToBase64(silent), frames, { isSpeech: false }); + socket.send(JSON.stringify(event)); + for (const message of profile.commitMessages()) socket.send(JSON.stringify(message)); + } + + function releaseVisionTurn(responseId) { + if (!profile.visionFollowWhileSpeaking || !visionTurnLocked) return; + if (responseId && currentResponseId && responseId !== currentResponseId) return; + visionTurnLocked = false; + } + + function suppressLocalPlayback() { + // Hold-to-talk cuts the speaker only. The in-flight response stays on the server. + if (currentResponseId) suppressedPlaybackId = currentResponseId; + if (playbackNode) playbackNode.port.postMessage({ type: 'clear' }); + playbackComplete = true; + responseHasAudio = false; + setPlayback('Idle'); + } + + function playbackSuppressed(responseId) { + return Boolean(suppressedPlaybackId) && responseId === suppressedPlaybackId; } function flushCapture() { - if (!socket || socket.readyState !== WebSocket.OPEN || pendingCapture.length === 0) return; + if (!socket || socket.readyState !== WebSocket.OPEN) return; + + // AURA: capture stays 2 fps, but a turn opens only every 2 frames, and + // not until the previous response's text is done (or it listened). + // Playback still running is not the lock. PTT speech is not batched here. + if (profile.pushToTalk && profile.visionFollowWhileSpeaking && !pttHeld) { + if (cameraPendingFrame) { + queueVisionFrame(cameraPendingFrame); + cameraPendingFrame = null; + } + if (!visionTurnLocked && visionFollowQueue.length >= 2) commitVisionFollow(); + return; + } + + if (profile.pushToTalk && !pttHeld) { + pendingCapture = []; + return; + } + + if (pendingCapture.length === 0) return; if (!microphoneUploadEnabled()) { pendingCapture = []; return; @@ -335,11 +416,44 @@ } pendingCapture = []; const pcm = resampleInt16(merged, captureRate, profile.inputSampleRate || INPUT_RATE); - const appendEvent = profile.append(int16ToBase64(pcm), cameraPendingFrame); + const frame = cameraPendingFrame || (profile.stickyCamera ? cameraLastFrame : null); cameraPendingFrame = null; + const appendEvent = profile.pushToTalk + ? profile.append(int16ToBase64(pcm), frame, { isSpeech: true }) + : profile.append(int16ToBase64(pcm), frame); socket.send(JSON.stringify(appendEvent)); } + function setPttHeld(held) { + if (!profile.pushToTalk || !running || pttHeld === held) return; + if (held) { + pttHeld = true; + suppressLocalPlayback(); + pttButton.classList.toggle('is-active', true); + pttButton.textContent = 'Release to send'; + setModel('Talking'); + appendLog('PTT down'); + return; + } + // Flush remaining speech while still held, then commit. + flushCapture(); + if (socket && socket.readyState === WebSocket.OPEN) { + for (const event of profile.commitMessages()) socket.send(JSON.stringify(event)); + } + // Lock vision before clearing pttHeld. Stage0/1 are max_num_seqs=1: a + // vision-follow commit in the gap before response.begin aborts the speech + // turn (server sees a new ephemeral and kills the prior ASR request). + if (profile.visionFollowWhileSpeaking) { + visionTurnLocked = true; + visionFollowQueue = []; + } + pttHeld = false; + pttButton.classList.toggle('is-active', false); + pttButton.textContent = 'Hold to talk'; + setModel(profile.waiting); + appendLog('PTT up · commit'); + } + function beginAssistant(responseId) { currentResponseId = responseId || currentResponseId; responseHasAudio = false; @@ -351,6 +465,7 @@ } function feedPlayback(decoded, responseId) { + if (playbackSuppressed(responseId || currentResponseId)) return; if (!decoded || !decoded.pcm || decoded.pcm.length === 0 || !playbackNode) return; const pcm = resampleInt16(decoded.pcm, decoded.sourceRate, playbackRate); responseHasAudio = true; @@ -392,13 +507,18 @@ } async function handleAudioEvent(action) { + const responseId = action.responseId || currentResponseId || `turn-${turnCounter}`; + if (playbackSuppressed(responseId)) return; markBusy(); - currentResponseId = action.responseId || currentResponseId || `turn-${turnCounter}`; + currentResponseId = responseId; setModel('Speaking'); const generation = sessionGeneration; - const responseId = currentResponseId; const decoded = await decodeAudioDelta(action.event); - if (generation === sessionGeneration && !interruptedResponses.has(responseId)) feedPlayback(decoded, responseId); + if ( + generation === sessionGeneration + && !interruptedResponses.has(responseId) + && !playbackSuppressed(responseId) + ) feedPlayback(decoded, responseId); } function handleTranscriptEvent(action) { @@ -408,12 +528,43 @@ if (profile.deduplicateTranscript && assistantTextChannel && action.channel !== assistantTextChannel) return; if (action.text) assistantTextChannel = action.channel || assistantTextChannel; } - if (action.kind === 'text') addTranscript(action.role, action.text); - else if (profile.deduplicateTranscript && action.role === 'assistant') { + const responseId = action.responseId || null; + const existing = responseId && action.role === 'assistant' ? assistantRowByResponse.get(responseId) : null; + if (existing) { + if (action.kind === 'text') { + if (!action.text) return; + existing.value += action.text; + } else if (action.text) { + existing.value = action.text; + } + existing.text.textContent = existing.value; + liveAssistantTurn = existing; + return; + } + if ( + action.role === 'assistant' + && !liveAssistantTurn + && action.text + && action.text === lastClosedAssistantText + ) { + // response.done already closed this sentence. A later full transcript, + // including one stamped with a newer vision-follow response id, must + // not open a second bubble with the same words. + return; + } + if (action.kind === 'text') { + addTranscript(action.role, action.text); + if (action.role === 'assistant' && responseId && liveAssistantTurn) { + assistantRowByResponse.set(responseId, liveAssistantTurn); + } + return; + } + if (profile.deduplicateTranscript && action.role === 'assistant') { if (action.text) { const turn = ensureTurn('assistant'); turn.value = action.text; turn.text.textContent = action.text; + if (responseId) assistantRowByResponse.set(responseId, turn); } } else finishTranscript(action.role, action.text); } @@ -459,12 +610,14 @@ } case 'listen': assistantActive = false; + releaseVisionTurn(responseId); setModel(profile.waiting); break; case 'begin': if (profile.halfDuplex) armTurnTimeout(); if (echoTimer !== null) { clearTimeout(echoTimer); echoTimer = null; } beginAssistant(responseId); + if (profile.visionFollowWhileSpeaking) visionTurnLocked = true; break; case 'audio': await handleAudioEvent(action); @@ -474,11 +627,13 @@ break; case 'text': case 'text-final': handleTranscriptEvent(action); + if (action.kind === 'text-final' && action.role === 'assistant') releaseVisionTurn(responseId); break; case 'done': clearTimeout(turnTimeout); responseComplete = true; finishTranscript('assistant'); + releaseVisionTurn(responseId); requestPlaybackDrain(responseId); finishResponseIfReady(); break; @@ -671,7 +826,11 @@ await openSocket(); running = true; muted = false; + pttHeld = false; assistantActive = false; + visionFollowQueue = []; + visionTurnLocked = false; + suppressedPlaybackId = null; sendTimer = window.setInterval(flushCapture, profile.sendIntervalMs || SEND_INTERVAL_MS); startClock(); callButton.textContent = 'End session'; @@ -679,6 +838,7 @@ muteButton.disabled = false; cameraButton.disabled = !profile.camera; sendTurnButton.disabled = !profile.clientCommit; + pttButton.disabled = !profile.pushToTalk; setConnection('Connected', 'online'); setModel(profile.waiting); appendLog('session started'); @@ -698,8 +858,9 @@ cameraPreview.srcObject = cameraStream; cameraPreview.style.display = ''; await cameraPreview.play().catch(() => {}); - // Official omni-duplex cadence: one JPEG (quality 0.7) per ~1 s chunk, - // no client-side resize (the server normalizes at scale_resolution=448). + // JPEG quality 0.7; no client-side resize (server scale_resolution=448). + // Interval is per-profile: AURA 2 fps, MiniCPM/Qwen stay at 1 s. + const cameraIntervalMs = profile.cameraIntervalMs || 1000; const captureCameraFrame = () => { if (!cameraStream || cameraPreview.videoWidth === 0) return; const scale = profile.cameraMaxDimension @@ -712,14 +873,17 @@ if (connectionReady && socket?.readyState === WebSocket.OPEN) { for (const event of profile.imageMessages(frame)) socket.send(JSON.stringify(event)); } - } else cameraPendingFrame = frame; + } else { + cameraPendingFrame = frame; + if (profile.stickyCamera) cameraLastFrame = frame; + } }; // Do not make the first spoken turn race a one-second timer. captureCameraFrame(); - cameraTimer = window.setInterval(captureCameraFrame, 1000); + cameraTimer = window.setInterval(captureCameraFrame, cameraIntervalMs); cameraButton.textContent = 'Camera off'; cameraButton.classList.add('is-active'); - appendLog('camera on (1 fps omni frames)'); + appendLog(`camera on (${(1000 / cameraIntervalMs).toFixed(0)} fps omni frames)`); } function stopCamera() { @@ -730,6 +894,7 @@ } cameraStream = null; cameraPendingFrame = null; + cameraLastFrame = null; cameraPreview.srcObject = null; cameraPreview.style.display = 'none'; cameraButton.textContent = 'Camera'; @@ -778,9 +943,18 @@ playbackComplete = true; turnSubmitted = false; assistantTextChannel = null; + assistantRowByResponse.clear(); + lastClosedAssistantText = ''; sendTurnButton.disabled = true; + pttHeld = false; + pttButton.disabled = true; + pttButton.classList.remove('is-active'); + pttButton.textContent = 'Hold to talk'; running = false; assistantActive = false; + visionFollowQueue = []; + visionTurnLocked = false; + suppressedPlaybackId = null; pendingCapture = []; if (sendTimer !== null) clearInterval(sendTimer); if (clockTimer !== null) clearInterval(clockTimer); @@ -854,6 +1028,23 @@ setModel('Thinking'); appendLog('turn submitted'); }); + const pttDown = (event) => { + event.preventDefault(); + setPttHeld(true); + }; + const pttUp = (event) => { + event.preventDefault(); + setPttHeld(false); + }; + pttButton.addEventListener('pointerdown', pttDown); + pttButton.addEventListener('pointerup', pttUp); + pttButton.addEventListener('pointercancel', pttUp); + pttButton.addEventListener('pointerleave', () => { + if (pttHeld) setPttHeld(false); + }); + window.addEventListener('pointerup', () => { + if (pttHeld) setPttHeld(false); + }); muteButton.addEventListener('click', toggleMute); clearLogButton.addEventListener('click', () => { eventLog.textContent = ''; diff --git a/examples/online_serving/realtime_web/app/static/profiles/aura_ptt.js b/examples/online_serving/realtime_web/app/static/profiles/aura_ptt.js new file mode 100644 index 000000000..242aa1b6a --- /dev/null +++ b/examples/online_serving/realtime_web/app/static/profiles/aura_ptt.js @@ -0,0 +1,79 @@ +(function (root) { + 'use strict'; + const profiles = root.OmniRealtimeProfiles; + profiles['aura-ptt'] = () => ({ + title: 'AURA Realtime', + eyebrow: 'Push-to-talk duplex', + policy: 'Hold to talk • release to commit', + description: + 'Hold to talk, release to send. Camera stays on whether you speak or not. ' + + 'Holding the button stops local playback.', + waiting: 'Ready', + camera: true, + // Display only. Shared CSS puts .camera-preview-large on its own row. + // Other profiles keep the 96×72 preview in index.html. + cameraPreviewLarge: true, + playbackAck: true, + clientCommit: false, + pushToTalk: true, + stickyCamera: true, + // OmniInteract / Native AURA clock: 2 fps. Other profiles omit this and stay at 1 s. + cameraIntervalMs: 500, + visionFollowWhileSpeaking: true, + deduplicateTranscript: true, + halfDuplex: false, + closeSession: true, + reconnectEachTurn: false, + readyEvent: 'session.updated', + instructions: true, + sendIntervalMs: 200, + presets: { + omni: 'You are receiving a live video stream where the final frame is the present moment. Respond only when a response is needed based on the user\'s message or the visual context. Otherwise, output `<|silent|>` to signify silence.', + }, + url(config, location) { + const url = profiles.url(config, location); + url.searchParams.set('duplex', '1'); + url.searchParams.set('model', config.model || 'aurateam/AURA'); + return url.toString(); + }, + initialMessages(config, instructions) { + const session = { + modalities: ['audio', 'text'], + extra_body: { auto_response: true }, + }; + if (instructions) session.instructions = instructions; + return [{ type: 'session.update', session }]; + }, + append(audio, frame, opts) { + const isSpeech = !opts || opts.isSpeech !== false; + const event = { + type: 'input_audio_buffer.append', + audio, + format: 'pcm16', + sample_rate_hz: 16000, + is_speech: isSpeech, + }; + const frames = Array.isArray(frame) ? frame.filter(Boolean) : (frame ? [frame] : []); + if (frames.length) event.video_frames = frames; + return event; + }, + commitMessages: () => [{ type: 'input_audio_buffer.commit', create_response: true }], + ack(responseId, playedMs) { + return { + type: 'playback.ack', + response_id: responseId, + item_id: `item_${responseId}`, + played_ms: playedMs, + committed_ms: playedMs, + }; + }, + mapEvent(event) { + if (event.type === 'response.listen') return { kind: 'listen' }; + if (event.type === 'playback.acknowledged') { + return { kind: 'ack', committedMs: (event.event || event).committed_ms || 0 }; + } + return profiles.event(event); + }, + connectionHint: 'Check the AURA duplex serve (run_duplex_smoke_serve.sh) and --ws-backend port.', + }); +})(globalThis); diff --git a/examples/online_serving/realtime_web/app/static/styles.css b/examples/online_serving/realtime_web/app/static/styles.css index bf25c3eff..53f98df7c 100644 --- a/examples/online_serving/realtime_web/app/static/styles.css +++ b/examples/online_serving/realtime_web/app/static/styles.css @@ -71,6 +71,18 @@ main { width: min(1040px, calc(100% - 40px)); margin: 28px auto 48px; } } .call-actions { display: flex; gap: 8px; } +/* AURA only: class is added solely by the aura-ptt profile. */ +.call-bar:has(.camera-preview-large) .call-actions { grid-column: 1 / -1; } +.call-actions:has(.camera-preview-large) { flex-wrap: wrap; align-items: center; } +.call-actions:has(.camera-preview-large) .camera-preview-large { + order: -1; + flex: 0 0 100%; + width: 100% !important; + height: auto !important; + max-height: 480px; + aspect-ratio: 4 / 3; +} +.call-actions:has(.camera-preview-large) button { flex: 0 0 auto; } button { border-radius: 5px; border: 1px solid transparent; cursor: pointer; } button:disabled { opacity: .46; cursor: default; } .primary { padding: 10px 15px; color: #ffffff; background: #16834b; font-weight: 700; } diff --git a/examples/online_serving/realtime_web/server.py b/examples/online_serving/realtime_web/server.py index 85e4b63d5..f6a10c4d3 100644 --- a/examples/online_serving/realtime_web/server.py +++ b/examples/online_serving/realtime_web/server.py @@ -76,15 +76,20 @@ def build_app( profile: str = "minicpm-native", adapter: str = "stt", ) -> FastAPI: - if profile not in {"minicpm-native", "qwen3-turn"}: + if profile not in {"minicpm-native", "qwen3-turn", "aura-ptt"}: raise ValueError(f"Unknown profile: {profile}") if adapter not in {"stt", "vad"}: raise ValueError(f"Unknown adapter: {adapter}") if profile == "qwen3-turn" and ref_audio: raise ValueError("Qwen3 profile does not accept --ref-audio") + if profile == "aura-ptt" and ref_audio: + raise ValueError("AURA profile does not accept --ref-audio") native = profile == "minicpm-native" - ws_backend = ws_backend or ("ws://127.0.0.1:8099" if native else "ws://127.0.0.1:8091") - model = model or ("openbmb/MiniCPM-o-4_5" if native else "Qwen/Qwen3-Omni-30B-A3B-Instruct") + aura = profile == "aura-ptt" + ws_backend = ws_backend or ("ws://127.0.0.1:8099" if native or aura else "ws://127.0.0.1:8091") + model = model or ( + "openbmb/MiniCPM-o-4_5" if native else "aurateam/AURA" if aura else "Qwen/Qwen3-Omni-30B-A3B-Instruct" + ) app = FastAPI(title="Omni Realtime Web UI") index_path = APP_DIR / "index.html" app_version_hash = hashlib.sha256() @@ -182,7 +187,7 @@ def main(default_profile: str = "minicpm-native") -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--host", default="0.0.0.0") parser.add_argument("--port", type=int, default=7862) - parser.add_argument("--profile", choices=("minicpm-native", "qwen3-turn"), default=default_profile) + parser.add_argument("--profile", choices=("minicpm-native", "qwen3-turn", "aura-ptt"), default=default_profile) parser.add_argument("--ws-backend", "--backend", dest="ws_backend") mode = parser.add_mutually_exclusive_group() mode.add_argument("--stt", dest="adapter", action="store_const", const="stt") @@ -202,12 +207,15 @@ def main(default_profile: str = "minicpm-native") -> None: ) args = parser.parse_args() native = args.profile == "minicpm-native" + aura = args.profile == "aura-ptt" if native and not args.ref_audio: parser.error("--ref-audio is required for the minicpm-native profile") if not native and args.ref_audio: parser.error("--ref-audio is only supported by minicpm-native") - args.model = args.model or ("openbmb/MiniCPM-o-4_5" if native else "Qwen/Qwen3-Omni-30B-A3B-Instruct") - args.ws_backend = args.ws_backend or ("ws://127.0.0.1:8099" if native else "ws://127.0.0.1:8091") + args.model = args.model or ( + "openbmb/MiniCPM-o-4_5" if native else "aurateam/AURA" if aura else "Qwen/Qwen3-Omni-30B-A3B-Instruct" + ) + args.ws_backend = args.ws_backend or ("ws://127.0.0.1:8099" if native or aura else "ws://127.0.0.1:8091") logging.basicConfig(level=logging.INFO) uvicorn.run( diff --git a/tests/benchmarks/test_aura_omniinteract_clock.py b/tests/benchmarks/test_aura_omniinteract_clock.py new file mode 100644 index 000000000..5e815f5fe --- /dev/null +++ b/tests/benchmarks/test_aura_omniinteract_clock.py @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from vllm_omni.benchmarks.omniinteract import _session_capabilities, _wants_annotation_video_clock +from vllm_omni.model_executor.models.aura_omni.benchmarks.omniinteract_clock import ( + stream_annotation_clock, + wants_annotation_video_clock, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _Events: + def __init__(self, events): + self.events = events + + +class _Client: + def __init__(self, capabilities): + self.events = _Events([{"type": "session.created", "session": {"capabilities": capabilities}}]) + self._client = None + + +def test_annotation_clock_follows_capabilities_not_model_name(): + aura = {"required_input_modalities": ["video"], "supports_turn_commit_only": True} + minicpm = {"required_input_modalities": ["audio"], "supports_turn_commit_only": False} + audio_commit = {"required_input_modalities": ["audio"], "supports_turn_commit_only": True} + assert wants_annotation_video_clock(aura) + assert not wants_annotation_video_clock(minicpm) + assert not wants_annotation_video_clock(audio_commit) + assert not wants_annotation_video_clock(None) + assert _wants_annotation_video_clock(_Client(minicpm)) is False + assert _wants_annotation_video_clock(_Client(aura)) is True + assert _session_capabilities(_Client(aura))["required_input_modalities"] == ["video"] + + +def test_annotation_clock_commits_only_the_due_wav(monkeypatch, tmp_path: Path): + video = tmp_path / "videos" / "0002.mp4" + video.parent.mkdir() + video.write_bytes(b"") + sent = [] + + class Client: + async def send(self, event): + sent.append(event) + + async def commit(self): + sent.append({"type": "commit"}) + + class Playback: + async def acknowledge(self, client): + del client + + monkeypatch.setattr( + "vllm_omni.model_executor.models.aura_omni.benchmarks.omniinteract_clock.load_annotation_utterances", + lambda path: [{"at_sec": 0.0, "pcm": b"\x01\x00" * 160, "sent": False, "qa_index": 0}], + ) + chunks, frames, _, _ = asyncio.run( + stream_annotation_clock( + Client(), + ["frame-a", "frame-b"], + Playback(), + video_path=video, + fps=2.0, + ) + ) + assert (chunks, frames) == (2, 2) + assert sent[0]["is_speech"] is True + assert sent[0]["video_frames"] == ["frame-a"] + assert sent[1] == {"type": "commit"} + # The trailing silent frame is one vision turn, committed after the clip. + assert sent[2]["is_speech"] is False + assert sent[2]["video_frames"] == ["frame-b"] + assert sent[3] == {"type": "commit"} + assert sent.count({"type": "commit"}) == 2 + + +def test_annotation_clock_commits_silent_frames_in_pairs(monkeypatch, tmp_path: Path): + video = tmp_path / "videos" / "0002.mp4" + video.parent.mkdir() + video.write_bytes(b"") + sent = [] + + class Client: + async def send(self, event): + sent.append(event) + + async def commit(self): + sent.append({"type": "commit"}) + + class Playback: + async def acknowledge(self, client): + del client + + monkeypatch.setattr( + "vllm_omni.model_executor.models.aura_omni.benchmarks.omniinteract_clock.load_annotation_utterances", + lambda path: [{"at_sec": 100.0, "pcm": b"\x01\x00" * 160, "sent": False, "qa_index": 0}], + ) + asyncio.run( + stream_annotation_clock( + Client(), + ["f1", "f2", "f3", "f4"], + Playback(), + video_path=video, + fps=2.0, + ) + ) + vision = [event for event in sent if event.get("is_speech") is False] + assert [event["video_frames"] for event in vision] == [["f1", "f2"], ["f3", "f4"]] + assert sent.count({"type": "commit"}) == 2 + + +def test_annotation_clock_holds_vision_while_a_response_is_open(monkeypatch, tmp_path: Path): + video = tmp_path / "videos" / "0002.mp4" + video.parent.mkdir() + video.write_bytes(b"") + sent = [] + + class Events: + def __init__(self): + self.events = [{"type": "response.created", "response": {"id": "resp-open"}}] + + class Client: + def __init__(self): + self.events = Events() + + async def send(self, event): + sent.append(event) + + async def commit(self): + sent.append({"type": "commit"}) + + class Playback: + async def acknowledge(self, client): + del client + + monkeypatch.setattr( + "vllm_omni.model_executor.models.aura_omni.benchmarks.omniinteract_clock.load_annotation_utterances", + lambda path: [{"at_sec": 100.0, "pcm": b"\x01\x00" * 160, "sent": False, "qa_index": 0}], + ) + asyncio.run( + stream_annotation_clock( + Client(), + ["f1", "f2", "f3", "f4"], + Playback(), + video_path=video, + fps=2.0, + ) + ) + assert sent == [] diff --git a/tests/config/test_environment_variables.py b/tests/config/test_environment_variables.py index e1561dab8..94f6c0647 100644 --- a/tests/config/test_environment_variables.py +++ b/tests/config/test_environment_variables.py @@ -167,7 +167,7 @@ def test_inventory_matches_reviewed_snapshot_counts(): EnvironmentVariableCategory.PUBLIC_OMNI: 29, EnvironmentVariableCategory.INHERITED_VLLM: 20, EnvironmentVariableCategory.PLATFORM_EXTERNAL: 27, - EnvironmentVariableCategory.MODEL_SPECIFIC: 57, + EnvironmentVariableCategory.MODEL_SPECIFIC: 59, EnvironmentVariableCategory.BENCHMARK_TRANSITIONAL: 21, EnvironmentVariableCategory.INTERNAL: 2, } @@ -181,7 +181,7 @@ def test_inventory_matches_reviewed_snapshot_counts(): ModelEnvironmentVariableDisposition.PROMOTE: 31, ModelEnvironmentVariableDisposition.REQUEST_SCOPE: 5, ModelEnvironmentVariableDisposition.EXTERNAL: 0, - ModelEnvironmentVariableDisposition.INTERNALIZE: 14, + ModelEnvironmentVariableDisposition.INTERNALIZE: 16, ModelEnvironmentVariableDisposition.DEPRECATE_REMOVE: 7, } diff --git a/tests/engine/duplex/test_aura_resumable_hooks.py b/tests/engine/duplex/test_aura_resumable_hooks.py new file mode 100644 index 000000000..e2ed43372 --- /dev/null +++ b/tests/engine/duplex/test_aura_resumable_hooks.py @@ -0,0 +1,725 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Public duplex hook: resumable defaults True; ephemeral ids when False.""" + +from __future__ import annotations + +import pytest +from vllm.sampling_params import SamplingParams + +from vllm_omni.engine.duplex.config import DuplexCapabilities +from vllm_omni.engine.duplex.contracts import ( + DuplexFence, + DuplexStageRequestContext, + DuplexStageSubmission, + duplex_ephemeral_stage_request_id, +) +from vllm_omni.engine.duplex.session.manager import DuplexSessionManager + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_submission_resumable_default_preserves_minicpm() -> None: + ctx = DuplexStageRequestContext( + request_id="r", + session_id="s", + fence=DuplexFence("s"), + stage_id=0, + final_stage_id=2, + config_generation=0, + sampling_params=(SamplingParams(max_tokens=1),), + ) + submission = DuplexStageSubmission( + context=ctx, + prompt={"prompt_token_ids": [1, 2]}, + already_submitted=False, + ) + assert submission.resumable is True + + +def test_stage_request_id_respects_resumable_flag() -> None: + fence = DuplexFence("sess", epoch=3, turn_id=9) + resumable_id = DuplexSessionManager.stage_request_id(fence, stage_id=0, resumable=True) + ephemeral_id = DuplexSessionManager.stage_request_id(fence, stage_id=0, resumable=False) + assert ephemeral_id == duplex_ephemeral_stage_request_id(fence, stage_id=0) + assert resumable_id != ephemeral_id + assert ephemeral_id.endswith("stage0-turn9") + from vllm_omni.engine.duplex.contracts import duplex_turn_id_from_request_id + + assert duplex_turn_id_from_request_id(ephemeral_id) == 9 + assert duplex_turn_id_from_request_id(resumable_id) is None + + +def test_capabilities_expose_concurrent_turn_requests_default_false() -> None: + caps = DuplexCapabilities() + assert caps.supports_concurrent_turn_requests is False + assert caps.allows_video_without_audio() is False + assert caps.required_input_modalities == frozenset({"audio"}) + assert caps.optional_input_modalities == frozenset({"video"}) + assert caps.supports_core_resumable_request is False + payload = caps.as_dict() + assert payload["supports_concurrent_turn_requests"] is False + assert payload["required_input_modalities"] == ["audio"] + assert payload["optional_input_modalities"] == ["video"] + + +def test_next_commit_allowed_soft_opens_on_r4_release() -> None: + from types import SimpleNamespace + + from vllm_omni.engine.duplex.session import helpers + + caps = DuplexCapabilities(supports_concurrent_turn_requests=True) + session = SimpleNamespace(active_response_id="resp", capabilities=caps) + tasks = SimpleNamespace( + active_response_task=None, + has_response_bound_append_tasks=lambda: False, + ) + # Monkeypatch assistant_playback_active via response_in_progress path: active_response_id set. + assert helpers.response_in_progress(session, tasks) is True + assert helpers.next_commit_allowed(session, tasks, concurrent_turn_requests_released=False) is False + assert helpers.next_commit_allowed(session, tasks, concurrent_turn_requests_released=True) is True + + caps_off = DuplexCapabilities(supports_concurrent_turn_requests=False) + session_off = SimpleNamespace(active_response_id="resp", capabilities=caps_off) + assert helpers.next_commit_allowed(session_off, tasks, concurrent_turn_requests_released=True) is False + + +def test_active_response_accepts_own_turn_when_concurrent_turn_requests() -> None: + """Approach A: each response owns its turn; draining TTS uses request→response map.""" + from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig + from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession + + session = DuplexEngineSession( + session_id="s", + config=DuplexSessionConfig(model="m", modalities=["text"]), + capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True), + ) + session.begin_response(turn_id=3) + assert session.active_response_accepts_model_turn(3) + assert not session.active_response_accepts_model_turn(4) + assert not session.active_response_accepts_model_turn(2) + session.bind_draining_request("req-old", "resp-old") + assert session.response_id_for_request("req-old") == "resp-old" + assert session.is_draining_request("req-old") + + # Ending the active response must not wipe older draining bindings. + session.bind_draining_request("req-newer-tts", session.active_response_id or "resp-active") + ended = session.active_response_id + session.end_response(commit_text=False) + assert session.is_draining_request("req-old") + assert session.response_id_for_request("req-old") == "resp-old" + assert not session.is_draining_request("req-newer-tts") + session.clear_draining_for_response("resp-old") + assert not session.is_draining_request("req-old") + del ended + + strict = DuplexEngineSession( + session_id="s2", + config=DuplexSessionConfig(model="m", modalities=["text"]), + capabilities=DuplexCapabilities(supports_concurrent_turn_requests=False), + ) + strict.begin_response(turn_id=3) + assert strict.active_response_accepts_model_turn(3) + assert not strict.active_response_accepts_model_turn(4) + + +def test_draining_request_exempt_from_completed_turn_filter() -> None: + """Draining ownership must win over the model_turn_id < turn_id late-audio guard.""" + from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig + from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession + + session = DuplexEngineSession( + session_id="s-drain", + config=DuplexSessionConfig(model="m", modalities=["text", "audio"]), + capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True), + ) + session.begin_response(turn_id=1) + r1 = session.active_response_id + assert r1 is not None + session.bind_draining_request("req-r1-tts", r1) + # Overlapping R2 becomes active, then finishes — must not wipe R1 draining. + session.begin_response(turn_id=2) + session.end_response(commit_text=False) + session.turn_id = 2 + assert session.active_response_id is None + assert session.is_draining_request("req-r1-tts") + assert session.response_id_for_request("req-r1-tts") == r1 + draining_response_id = session.response_id_for_request("req-r1-tts") + model_turn_id = 1 + drop = ( + draining_response_id is None + and session.active_response_id is None + and model_turn_id is not None + and model_turn_id < session.turn_id + ) + assert drop is False + + +def test_ephemeral_turn_id_parser_accepts_stage_turn_ids() -> None: + from vllm_omni.engine.duplex.contracts import duplex_turn_id_from_request_id + + assert duplex_turn_id_from_request_id("duplex-s.x.e.0.r.stage0-turn12") == 12 + assert duplex_turn_id_from_request_id("duplex-s.x.e.0.r.stage2-turn3") == 3 + assert duplex_turn_id_from_request_id("duplex-s.x.e.0.r.stage0_t9") is None + assert duplex_turn_id_from_request_id("duplex-s.x.e.0.r.stage0") is None + + +def test_same_turn_request_ids_skip_other_turns_and_epochs() -> None: + from vllm_omni.engine.duplex.contracts import duplex_same_turn_request_ids + + stage3 = "duplex-s.x.e.0.r.stage3-turn4" + found = duplex_same_turn_request_ids( + stage3, + [ + "duplex-s.x.e.0.r.stage0-turn4", + "duplex-s.x.e.0.r.stage1-turn4", + "duplex-s.x.e.0.r.stage2-turn4", + stage3, + "duplex-s.x.e.0.r.stage2-turn5", + "duplex-s.x.e.1.r.stage0-turn4", + ], + ) + assert found == [ + "duplex-s.x.e.0.r.stage0-turn4", + "duplex-s.x.e.0.r.stage1-turn4", + "duplex-s.x.e.0.r.stage2-turn4", + ] + from vllm_omni.engine.duplex.contracts import duplex_session_id_from_request_id + + fence_session = "sess id" + import base64 + + encoded = base64.urlsafe_b64encode(fence_session.encode()).decode("ascii").rstrip("=") + assert duplex_session_id_from_request_id(f"duplex-s.{encoded}.e.0.r.stage1-turn2") == fence_session + assert duplex_session_id_from_request_id("not-a-request") is None + + +def test_stale_keys_skip_already_draining_request_ids() -> None: + """Rebinding must not move an older draining Stage2/3 id onto the newer response.""" + from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig + from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession + + session = DuplexEngineSession( + session_id="s-stale", + config=DuplexSessionConfig(model="m", modalities=["text", "audio"]), + capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True), + ) + session.begin_response(turn_id=1) + r1 = session.active_response_id + assert r1 is not None + session.bind_draining_request("req-r1-talker", r1) + session.begin_response(turn_id=2) + r2 = session.active_response_id + assert r2 is not None and r2 != r1 + # Simulate the concurrent-turn rebind loop: already-draining ids keep R1. + for rid in ("req-r1-talker", "req-r2-talker"): + if session.is_draining_request(rid): + continue + session.bind_draining_request(rid, r2) + assert session.response_id_for_request("req-r1-talker") == r1 + assert session.response_id_for_request("req-r2-talker") == r2 + + +def test_end_response_clears_only_own_draining_entries() -> None: + from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig + from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession + + session = DuplexEngineSession( + session_id="s-end", + config=DuplexSessionConfig(model="m", modalities=["text", "audio"]), + capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True), + ) + session.begin_response(turn_id=1) + r1 = session.active_response_id + session.bind_draining_request("req-r1", r1) + session.begin_response(turn_id=2) + r2 = session.active_response_id + session.bind_draining_request("req-r2", r2) + session.end_response(commit_text=False) + assert session.is_draining_request("req-r1") + assert session.response_id_for_request("req-r1") == r1 + assert not session.is_draining_request("req-r2") + + +def test_on_stage_failure_resolves_draining_response_before_active() -> None: + """Mirrors runner.on_stage_failure: draining request_id owns the failed done.""" + from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig + from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession + + session = DuplexEngineSession( + session_id="s-fail", + config=DuplexSessionConfig(model="m", modalities=["text", "audio"]), + capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True), + ) + session.begin_response(turn_id=1) + r1 = session.active_response_id + assert r1 is not None + session.bind_draining_request("req-r1-talker", r1) + session.begin_response(turn_id=2) + r2 = session.active_response_id + assert r2 is not None and r2 != r1 + + request_id = "req-r1-talker" + draining_response_id = ( + session.response_id_for_request(request_id) if session.is_draining_request(request_id) else None + ) + response_id = draining_response_id or session.active_response_id + assert response_id == r1 + assert response_id != r2 + session.clear_draining_for_response(r1) + assert not session.is_draining_request(request_id) + assert session.active_response_id == r2 + + +def test_concurrent_turn_requests_released_resets_when_new_stage0_binds() -> None: + """After R4 release, a new ephemeral Stage0 bind must clear the gate flag.""" + from types import SimpleNamespace + + run = SimpleNamespace(concurrent_turn_requests_released=True) + # Simulate the concurrent-turn rebind arm in model_channel._append_via_data_plane. + concurrent_turn = True + if concurrent_turn: + run.concurrent_turn_requests_released = False + assert run.concurrent_turn_requests_released is False + + +def test_draining_stage_ids_come_from_the_plugin() -> None: + from types import SimpleNamespace + + from vllm_omni.engine.duplex.session.model_channel import ModelChannel + + channel = ModelChannel.__new__(ModelChannel) + channel._ctx = SimpleNamespace( + plugin=SimpleNamespace(draining_stage_ids=lambda *, stage_count: {stage_count - 1}), + stage_port=SimpleNamespace(stage_count=5), + ) + assert channel._draining_stage_ids() == frozenset({4}) + + channel._ctx.plugin = SimpleNamespace() + assert channel._draining_stage_ids() == frozenset() + + +def test_draining_empty_eos_completes_owning_response_before_shortcut() -> None: + """R2 finishing first must not swallow R1's empty Stage3 EOS.""" + import asyncio + from types import SimpleNamespace + + from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig + from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession + from vllm_omni.engine.duplex.session.model_channel import ModelChannel + + session = DuplexEngineSession( + session_id="s-empty-eos", + config=DuplexSessionConfig(model="m", modalities=["text", "audio"], extra_body={"auto_response": True}), + capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True, supports_core_resumable_request=False), + ) + session.begin_response(turn_id=1) + r1 = session.active_response_id + assert r1 is not None + r1_request = "duplex-s.x.e.0.r.stage3-turn1" + still_running = "duplex-s.x.e.0.r.stage1-turn2" + session.bind_draining_request(r1_request, r1) + session.begin_response(turn_id=2) + session.end_response(commit_text=False) + session.turn_id = 2 + assert session.active_response_id is None + + class _Plane: + def is_terminal(self, request_id: str | None) -> bool: + del request_id + return False + + def close_stream(self, request_id: str) -> None: + del request_id + + def mark_terminal(self, request_id: str) -> None: + del request_id + + class _Port: + def __init__(self) -> None: + self.cleanups: list[list[str]] = [] + + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + del abort + self.cleanups.append(list(request_ids)) + + class _Out: + def __init__(self) -> None: + self.events: list[dict[str, object]] = [] + + def auto_responds(self) -> bool: + return True + + def emit(self, payload: dict[str, object]) -> None: + self.events.append(payload) + + port = _Port() + out = _Out() + + async def _noop(*_args: object, **_kwargs: object) -> None: + return None + + async def _schedule(*_args: object, **_kwargs: object) -> bool: + return False + + channel = ModelChannel( + SimpleNamespace( + session=session, + plugin=SimpleNamespace(data_plane=_Plane()), + stage_port=port, + model_state=SimpleNamespace(clear_continuation=lambda: None), + services=SimpleNamespace(spawn=lambda *_a, **_k: None), + ), + out, + close_from_runtime=_noop, + schedule_silence_continuation=_schedule, + abort_request=_noop, + ) + asyncio.run( + channel._send_one_model_output_event( + { + "data_plane_request_id": r1_request, + "end_of_turn": True, + "model_turn_id": 1, + "text": "", + "stage_role": "tts", + } + ) + ) + done = [event for event in out.events if event.get("type") == "response.done"] + assert done and done[-1].get("response_id") == r1 + assert port.cleanups == [[r1_request]] + assert all(still_running not in ids for ids in port.cleanups) + assert not session.is_draining_request(r1_request) + + +def test_draining_completion_drops_finished_response_books_only() -> None: + """R1 drain finish drops R1 books and leaves the live R2 snapshot.""" + import asyncio + from types import SimpleNamespace + + from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig + from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession + from vllm_omni.engine.duplex.session.model_channel import ModelChannel + + session = DuplexEngineSession( + session_id="s-drain-books", + config=DuplexSessionConfig(model="m", modalities=["text", "audio"], extra_body={"auto_response": True}), + capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True, supports_core_resumable_request=False), + ) + session.begin_response(turn_id=1) + r1 = session.active_response_id + assert r1 is not None + session.append_assistant_text("from-r1") + session.snapshot_active_response_for_drain() + r1_request = "duplex-s.x.e.0.r.stage3-turn1" + session.bind_draining_request(r1_request, r1) + session.begin_response(turn_id=2) + r2 = session.active_response_id + assert r2 is not None and r2 != r1 + session.append_assistant_text("from-r2") + session.snapshot_active_response_for_drain() + + class _Plane: + def is_terminal(self, request_id: str | None) -> bool: + del request_id + return False + + def close_stream(self, request_id: str) -> None: + del request_id + + def mark_terminal(self, request_id: str) -> None: + del request_id + + class _Port: + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + del request_ids, abort + + class _Out: + def __init__(self) -> None: + self.events: list[dict[str, object]] = [] + + def auto_responds(self) -> bool: + return True + + def emit(self, payload: dict[str, object]) -> None: + self.events.append(payload) + + out = _Out() + + async def _noop(*_args: object, **_kwargs: object) -> None: + return None + + async def _schedule(*_args: object, **_kwargs: object) -> bool: + return False + + channel = ModelChannel( + SimpleNamespace( + session=session, + plugin=SimpleNamespace(data_plane=_Plane()), + stage_port=_Port(), + model_state=SimpleNamespace(clear_continuation=lambda: None), + services=SimpleNamespace(spawn=lambda *_a, **_k: None), + ), + out, + close_from_runtime=_noop, + schedule_silence_continuation=_schedule, + abort_request=_noop, + ) + asyncio.run( + channel._send_one_model_output_event( + { + "data_plane_request_id": r1_request, + "end_of_turn": True, + "model_turn_id": 1, + "text": "", + "stage_role": "tts", + } + ) + ) + assert r1 not in session._conversation.assistant_response_snapshots + assert r1 not in session._playback.by_response + assert f"item_{r1}" not in session._conversation.history_item_placeholders + assert r2 in session._conversation.assistant_response_snapshots + assert session.active_response_id == r2 + done = [event for event in out.events if event.get("type") == "response.done"] + assert done and done[-1].get("response_id") == r1 + + +def test_stale_continue_does_not_close_the_new_response() -> None: + import asyncio + from types import SimpleNamespace + + from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig + from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession + from vllm_omni.engine.duplex.session.model_channel import ModelChannel + + session = DuplexEngineSession( + session_id="s-stale-continue", + config=DuplexSessionConfig(model="m", modalities=["text", "audio"], extra_body={"auto_response": True}), + capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True, supports_core_resumable_request=False), + ) + session.begin_response(turn_id=2) + live = session.active_response_id + session.epoch = 1 + + class _Out: + def __init__(self) -> None: + self.events: list[dict[str, object]] = [] + + def auto_responds(self) -> bool: + return True + + def emit(self, payload: dict[str, object]) -> None: + self.events.append(payload) + + out = _Out() + cleared: list[bool] = [] + + async def _noop(*_args: object, **_kwargs: object) -> None: + return None + + async def _schedule(*_args: object, **_kwargs: object) -> bool: + return False + + channel = ModelChannel( + SimpleNamespace( + session=session, + plugin=SimpleNamespace(data_plane=SimpleNamespace()), + stage_port=SimpleNamespace(), + model_state=SimpleNamespace(clear_continuation=lambda: cleared.append(True)), + services=SimpleNamespace(spawn=lambda *_a, **_k: None), + run=SimpleNamespace(closing=False), + ), + out, + close_from_runtime=_noop, + schedule_silence_continuation=_schedule, + abort_request=_noop, + ) + asyncio.run(channel.maybe_continue_response(expected_epoch=0)) + assert session.active_response_id == live + assert out.events == [] + assert cleared == [] + + session.bind_draining_request("duplex-s.x.e.1.r.stage3-turn2", live or "") + asyncio.run(channel.maybe_continue_response(expected_epoch=1)) + assert session.active_response_id == live + assert out.events == [] + + session.clear_draining_requests() + asyncio.run(channel.maybe_continue_response(expected_epoch=1)) + assert session.active_response_id is None + assert [event.get("type") for event in out.events] == ["response.done"] + assert out.events[0].get("response_id") == live + + +def test_silent_listen_with_continuation_releases_ephemeral_request() -> None: + import asyncio + from collections.abc import Coroutine + from types import SimpleNamespace + from typing import Any + + from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig + from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession + from vllm_omni.engine.duplex.session.model_channel import ModelChannel + + session = DuplexEngineSession( + session_id="s-silent-continue", + config=DuplexSessionConfig(model="m", modalities=["text", "audio"]), + capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True, supports_core_resumable_request=False), + ) + session.begin_response(turn_id=1) + response_id = session.active_response_id + request_id = "duplex-s.x.e.0.r.stage1-turn1" + session.bind_request(request_id) + + class _Plane: + def __init__(self) -> None: + self.terminal: list[str] = [] + + def is_terminal(self, request_id: str | None) -> bool: + return request_id in self.terminal + + def mark_terminal(self, request_id: str) -> None: + self.terminal.append(request_id) + + class _Port: + def __init__(self) -> None: + self.cleanups: list[list[str]] = [] + + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + del abort + self.cleanups.append(list(request_ids)) + + class _Out: + def __init__(self) -> None: + self.events: list[dict[str, object]] = [] + + def auto_responds(self) -> bool: + return False + + def emit(self, payload: dict[str, object]) -> None: + self.events.append(payload) + + port = _Port() + out = _Out() + spawned: list[Coroutine[Any, Any, None]] = [] + + async def _noop(*_args: object, **_kwargs: object) -> None: + return None + + async def _schedule(*_args: object, **_kwargs: object) -> bool: + return False + + channel = ModelChannel( + SimpleNamespace( + session=session, + plugin=SimpleNamespace(data_plane=_Plane()), + stage_port=port, + model_state=SimpleNamespace( + continuation_owner_id=None, continuation_units=0, clear_continuation=lambda: None + ), + services=SimpleNamespace(spawn=lambda coro, **_k: spawned.append(coro)), + run=SimpleNamespace(closing=False), + ), + out, + close_from_runtime=_noop, + schedule_silence_continuation=_schedule, + abort_request=_noop, + ) + asyncio.run( + channel._send_one_model_output_event( + { + "data_plane_request_id": request_id, + "is_listen": True, + "end_of_turn": True, + "model_turn_id": 1, + "reason": "model_listen", + } + ) + ) + assert port.cleanups == [[request_id]] + assert session.active_request_id is None + assert spawned, "silent listen with unused continuation must schedule maybe_continue" + asyncio.run(spawned[0]) + assert port.cleanups == [[request_id]] + assert session.active_response_id is None + assert [event.get("type") for event in out.events] == ["response.listen", "response.done"] + assert out.events[0].get("response_id") == response_id + assert out.events[1].get("response_id") == response_id + + +def test_silent_listen_aborts_data_plane_request_with_full_id() -> None: + """abort_data_plane_request must pass [request_id], not a bare str.""" + import asyncio + from types import SimpleNamespace + + from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig + from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession + from vllm_omni.engine.duplex.session.model_channel import ModelChannel + + session = DuplexEngineSession( + session_id="s-silent-abort", + config=DuplexSessionConfig(model="m", modalities=["text", "audio"]), + capabilities=DuplexCapabilities(supports_concurrent_turn_requests=True, supports_core_resumable_request=False), + ) + request_id = "duplex-s.x.e.0.r.stage0-turn7" + session.bind_request(request_id) + aborts: list[list[str]] = [] + + class _Plane: + def is_terminal(self, request_id: str | None) -> bool: + return False + + def mark_terminal(self, request_id: str) -> None: + del request_id + + class _Port: + async def cleanup(self, request_ids: list[str], *, abort: bool = False) -> None: + del request_ids, abort + + class _Out: + def auto_responds(self) -> bool: + return True + + def emit(self, payload: dict[str, object]) -> None: + del payload + + async def _noop(*_args: object, **_kwargs: object) -> None: + return None + + async def _abort(request_ids: list[str], *, notify: bool = False) -> None: + del notify + aborts.append(list(request_ids)) + + channel = ModelChannel( + SimpleNamespace( + session=session, + plugin=SimpleNamespace(data_plane=_Plane()), + stage_port=_Port(), + model_state=SimpleNamespace( + continuation_owner_id=None, continuation_units=0, clear_continuation=lambda: None + ), + services=SimpleNamespace(spawn=lambda coro, **_k: None), + run=SimpleNamespace(closing=False), + ), + _Out(), + close_from_runtime=_noop, + schedule_silence_continuation=_noop, + abort_request=_abort, + ) + asyncio.run( + channel._send_one_model_output_event( + { + "data_plane_request_id": request_id, + "is_listen": True, + "end_of_turn": True, + "model_turn_id": 1, + "reason": "model_listen", + "abort_data_plane_request": True, + } + ) + ) + assert aborts == [[request_id]] + assert all(len(item) > 1 for batch in aborts for item in batch), "must not split request id into chars" diff --git a/tests/engine/duplex/test_engine_session.py b/tests/engine/duplex/test_engine_session.py index 5ef4ab6b9..358ee2d9c 100644 --- a/tests/engine/duplex/test_engine_session.py +++ b/tests/engine/duplex/test_engine_session.py @@ -242,6 +242,19 @@ def test_history_commit_with_ack_only_policy_defers_unacknowledged_text(): assert f"item_{response_id}" in session.pending_history_item_ids +def test_history_commit_with_ack_only_policy_keeps_only_acknowledged_prefix(): + session = _session(config=DuplexSessionConfig(playback_commit_policy="ack_only")) + session.begin_response() + session.append_assistant_text("hello world") + session.mark_audio_sent(duration_ms=1_000, text_chars=6) + session.mark_audio_sent(duration_ms=2_000, text_chars=11) + session.acknowledge_playback(played_ms=1_000, committed_ms=1_000) + + committed = session.end_response(commit_text=True) + + assert committed == {"role": "assistant", "content": "hello"} + + @pytest.mark.parametrize("audio_complete", [False, True]) def test_unaligned_response_keeps_empty_turn_before_later_user_input(audio_complete): session = _session(config=DuplexSessionConfig(playback_commit_policy="ack_only")) @@ -469,6 +482,36 @@ def test_cancel_fence_releases_stage_requests_and_advances_identity(): session.cancel_fence(DuplexFence("sid-other", epoch=1, turn_id=0), DuplexFence("sid-cancel", epoch=2)) +def test_request_resource_keys_are_stage_id_and_request_id(): + session = _session("sid-keys") + session.bind_stage_request(0, "req-a", fence=session.fence) + session.bind_stage_request(1, "req-b", fence=session.fence) + session.bind_stage_request(2, "req-tts", fence=session.fence) + stale_keys = list(session.request_resources.keys()) + stale_ids = list(dict.fromkeys(rid for _, rid in stale_keys)) + assert stale_ids == ["req-a", "req-b", "req-tts"] + for sid, rid in stale_keys: + if sid < 2: + session.request_resources.pop((sid, rid), None) + assert session.resource_request_ids() == ["req-tts"] + with pytest.raises(TypeError, match="unhashable"): + dict.fromkeys(rid for _, rid in session.request_resources.items()) + + +def test_release_resources_for_request_ids_keeps_other_ids_on_the_same_fence(): + session = _session("sid-drain-release") + fence = session.fence + session.bind_stage_request(2, "drain", fence=fence) + session.bind_stage_request(3, "drain", fence=fence) + session.bind_stage_request(0, "live", fence=fence) + + released = session.release_resources_for_request_ids(["drain"]) + + assert released == ["drain"] + assert session.resource_request_ids() == ["live"] + assert session.release_resources_for_request_ids([]) == [] + + # ---- public view ---- @@ -688,3 +731,65 @@ def test_minicpmo_native_capabilities_do_not_overclaim_single_session_deployment assert caps["supports_multi_session"] is False assert caps["supports_multi_session_same_replica"] is False + + +def test_draining_response_stays_ack_admissible_after_next_begin_response() -> None: + from vllm_omni.engine.duplex.events import ErrorEvent + from vllm_omni.engine.duplex.session.playback_ledger import apply_playback_ack + + session = _session() + first = session.begin_response(turn_id=1) + session.append_assistant_text("hello") + session.mark_audio_sent(400, text_chars=5) + session.snapshot_active_response_for_drain() + second = session.begin_response(turn_id=2) + assert second != first + assert session.playback.sent_ms == 0 + events = apply_playback_ack( + session, + { + "type": "playback.ack", + "response_id": first, + "item_id": f"item_{first}", + "played_ms": 100, + "committed_ms": 100, + }, + ) + assert not any(isinstance(event, ErrorEvent) and event.code == "playback_item_not_found" for event in events) + session.mark_audio_sent(900, text_chars=5, response_id=first) + assert session.playback_for_response(first).sent_ms == 900 + assert session.playback.sent_ms == 0 + + +def test_finished_drain_keeps_sent_audio_ackable() -> None: + from vllm_omni.engine.duplex.events import ErrorEvent + from vllm_omni.engine.duplex.session.playback_ledger import apply_playback_ack + + session = _session() + first = session.begin_response(turn_id=1) + session.append_assistant_text("hello") + session.mark_audio_sent(400, text_chars=5) + session.snapshot_active_response_for_drain() + second = session.begin_response(turn_id=2) + session.release_finished_drain_response(first) + assert first in session._conversation.assistant_response_snapshots + assert f"item_{first}" in session._conversation.history_item_placeholders + events = apply_playback_ack( + session, + { + "type": "playback.ack", + "response_id": first, + "item_id": f"item_{first}", + "played_ms": 400, + "committed_ms": 400, + }, + ) + assert not any(isinstance(event, ErrorEvent) for event in events) + assert session.active_response_id == second + + silent = session.begin_response(turn_id=3) + session.snapshot_active_response_for_drain() + session.begin_response(turn_id=4) + session.release_finished_drain_response(silent) + assert silent not in session._conversation.assistant_response_snapshots + assert f"item_{silent}" not in session._conversation.history_item_placeholders diff --git a/tests/engine/duplex/test_input_transcribed.py b/tests/engine/duplex/test_input_transcribed.py new file mode 100644 index 000000000..98914b899 --- /dev/null +++ b/tests/engine/duplex/test_input_transcribed.py @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import pytest + +from vllm_omni.engine.duplex.realtime_events import ( + RealtimeProjectionState, + project_internal_event, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_input_transcribed_is_a_user_transcript_event() -> None: + events = project_internal_event( + RealtimeProjectionState(session_id="s"), + {"type": "input.transcribed", "transcript": " 你好 "}, + ) + assert len(events) == 1 + assert events[0].wire_type == "conversation.item.input_audio_transcription.completed" + assert events[0].transcript == "你好" + + +def test_input_transcribed_updates_the_committed_item() -> None: + state = RealtimeProjectionState(session_id="s") + project_internal_event( + state, + { + "type": "input.committed", + "realtime_item_id": "item_user_1", + "is_speech": True, + "message": {"role": "user"}, + }, + ) + events = project_internal_event( + state, + {"type": "input.transcribed", "transcript": "你好", "realtime_item_id": "item_user_1"}, + ) + assert events[0].item_id == "item_user_1" + item = state.conversation_items["item_user_1"] + assert item["content"][0]["transcript"] == "你好" + + +def test_blank_input_transcribed_emits_nothing() -> None: + events = project_internal_event( + RealtimeProjectionState(session_id="s"), + {"type": "input.transcribed", "transcript": " "}, + ) + assert events == [] diff --git a/tests/engine/duplex/test_overlap_policy.py b/tests/engine/duplex/test_overlap_policy.py index 096b7c7c9..61840e2bb 100644 --- a/tests/engine/duplex/test_overlap_policy.py +++ b/tests/engine/duplex/test_overlap_policy.py @@ -79,6 +79,39 @@ def test_an_explicit_client_overlap_action_requests_barge_in() -> None: assert decision["reason"] == "client_overlap_action" +def test_turn_mode_keeps_silent_vision_follow_during_overlap() -> None: + """Vision-follow is is_speech=False + a frame; must not drop while TTS plays.""" + session = _session() + session.capabilities = type(session.capabilities)( + required_input_modalities=frozenset({"video"}), + optional_input_modalities=frozenset({"audio"}), + ) + payload = {"format": "pcm16", "audio": _pcm16(0.0), "video_frames": ["frame"], "is_speech": False} + decision = overlap_policy.decide(session, {"is_speech": False}, payload, auto_responds=False) + assert decision["action"] == "listen" + assert decision["reason"] == "vision_follow" + assert decision["buffer_audio"] is True + assert decision["defer_runtime_append"] is False + assert decision["force_listen"] is False + + +def test_turn_mode_drops_silent_vision_without_capability() -> None: + session = _session() + assert session.capabilities.allows_video_without_audio() is False + payload = {"format": "pcm16", "audio": _pcm16(0.0), "video_frames": ["frame"], "is_speech": False} + decision = overlap_policy.decide(session, {"is_speech": False}, payload, auto_responds=False) + assert decision["action"] == "drop" + assert decision["reason"] == "silence_or_noise" + + +def test_turn_mode_still_drops_silent_overlap_without_vision() -> None: + session = _session() + payload = {"format": "pcm16", "audio": _pcm16(0.0), "is_speech": False} + decision = overlap_policy.decide(session, {"is_speech": False}, payload, auto_responds=False) + assert decision["action"] == "drop" + assert decision["reason"] == "silence_or_noise" + + def test_barge_in_is_deferred_when_the_session_cannot_support_it() -> None: session = _session() session.capabilities = type(session.capabilities)(supports_barge_in=False) diff --git a/tests/engine/duplex/test_session_manager.py b/tests/engine/duplex/test_session_manager.py index bccce18f1..cb00737d9 100644 --- a/tests/engine/duplex/test_session_manager.py +++ b/tests/engine/duplex/test_session_manager.py @@ -196,7 +196,11 @@ class FakePlugin(DuplexModelPlugin): def capabilities(self, *, max_sessions: int) -> DuplexCapabilities: del max_sessions - return DuplexCapabilities(supports_input_append=True, supports_core_resumable_request=True) + # Resident Stage0 ids (MiniCPM-shaped); AURA opts out via supports_core_resumable_request=False. + return DuplexCapabilities( + supports_input_append=True, + supports_core_resumable_request=True, + ) def validate_client_extra_body(self, extra_body: object) -> None: pass @@ -409,7 +413,8 @@ async def test_open_answers_with_capabilities_and_emits_session_created() -> Non assert result.session_id == "sid-open" assert result.lease_generation == 0 assert result.capabilities == DuplexCapabilities( - supports_input_append=True, supports_core_resumable_request=True + supports_input_append=True, + supports_core_resumable_request=True, ) assert result.public_session is not None assert result.public_session["id"] == "sid-open" @@ -1028,6 +1033,40 @@ async def test_append_bytes_are_reserved_at_admission_until_the_runner_dequeues( gate.set() +async def test_append_admission_counts_audio_and_video_frame_bytes() -> None: + """Manager reserves len(audio)+Σlen(frame); video bytes count toward the same limit.""" + async with Harness.create(max_sessions=1, max_pending_input_bytes_per_session=20) as harness: + await harness.open("sid-av") + harness.events() + session = harness.session("sid-av") + runner = harness.manager.runners["sid-av"] + gate = asyncio.Event() + runner._mailbox.put_nowait(_Internal("wait", {"gate": gate})) + runner._on_internal = lambda item: gate.wait() # type: ignore[method-assign] + await asyncio.sleep(0) + + # Default caps require audio; attach video to a non-empty audio unit. + frame_a = "aaaa" + frame_b = "bbbbbb" + audio = b"1234" + expected = len(audio) + len(frame_a) + len(frame_b) + harness.command( + "sid-av", + AppendAudio(audio=audio, video_frames=(frame_a, frame_b), event_id="evt-av"), + ) + assert session.pending_input_bytes == expected + # Second append that would exceed the limit is backpressured. + harness.command( + "sid-av", + AppendAudio(audio=b"x" * 10, video_frames=("yyyyyyyyyy",), event_id="evt-over"), + ) + errors = [event for event in harness.events("sid-av") if isinstance(event, ErrorEvent)] + assert [error.code for error in errors] == ["input_backpressure"] + assert errors[0].related_event_id == "evt-over" + assert session.pending_input_bytes == expected + gate.set() + + async def test_expired_session_retains_the_admission_slot_until_cleanup_succeeds() -> None: async with Harness.create(max_sessions=1, idle_ttl_s=1.0) as harness: await harness.open("sid-expired") diff --git a/tests/engine/duplex/test_session_runner.py b/tests/engine/duplex/test_session_runner.py index 51711f52a..f48154a48 100644 --- a/tests/engine/duplex/test_session_runner.py +++ b/tests/engine/duplex/test_session_runner.py @@ -180,7 +180,10 @@ class Harness: segment_token_ids=tuple(segment_token_ids), segment_output_metadata=dict(segment_output_metadata or {}), ) - return self.runner.on_stage_output(stage_id, output, metrics, request_id=output.request_id, context=context) + request_id = getattr(output, "request_id", None) + if not isinstance(request_id, str): + raise AssertionError("stage output is missing request_id") + return self.runner.on_stage_output(stage_id, output, metrics, request_id=request_id, context=context) async def deliver_and_settle(self, output: object, **kwargs: Any) -> list[DuplexEvent]: self.deliver(output, **kwargs) @@ -520,6 +523,28 @@ async def test_commit_without_audio_is_rejected() -> None: await close_harness(h) +@pytest.mark.asyncio +async def test_empty_silent_commit_acks_without_opening_response() -> None: + """#3: empty is_speech=False commit keeps the silent-ack pair, even auto-respond.""" + h = await open_harness(auto_response=True) + try: + # resolve_commit only admits empty silent commits after non-speech was seen. + projector = h.runner._require_projector() + projector.input_audio_buffer_had_non_speech = True + events = await h.run(commands.Commit()) + assert "error" not in types(events), types(events) + assert "input_audio_buffer.committed" in types(events), types(events) + assert "response.listen" in types(events), types(events) + listen = next(event for event in events if event.type == "response.listen") + assert listen.details["reason"] == "silence_or_noise" + committed = next(event for event in events if event.type == "input_audio_buffer.committed") + assert committed.details.get("empty") is True + assert committed.details.get("no_response") is True + assert h.port.submissions == [] + finally: + await close_harness(h) + + @pytest.mark.asyncio async def test_commit_projects_the_user_item_and_does_not_resubmit_consumed_audio() -> None: h = await open_harness() @@ -674,6 +699,45 @@ async def test_stale_epoch_output_is_dropped_after_barge_in() -> None: await close_harness(h) +async def test_barge_in_aborts_draining_tts_as_well_as_the_active_request() -> None: + h = await open_harness() + try: + await h.run(append_audio()) + request_id = h.stage0_request_id() + await h.deliver_and_settle(tts_output(request_id, samples=24000, text="he")) + h.session.bind_draining_request("duplex-drain-tts", "resp-old") + # Draining TTS sits on an older turn fence. cancel_fence only drops + # the fence being cancelled, so these bindings must be popped by id. + from vllm_omni.engine.duplex.contracts import DuplexFence + from vllm_omni.engine.duplex.session.engine_session import DuplexRequestResource + + older = DuplexFence(h.session.session_id, epoch=h.session.epoch, turn_id=h.session.turn_id + 5) + h.session.request_resources[(2, "duplex-drain-tts")] = DuplexRequestResource( + stage_id=2, request_id="duplex-drain-tts", fence=older, submitted=True + ) + h.session.request_resources[(3, "duplex-drain-tts")] = DuplexRequestResource( + stage_id=3, request_id="duplex-drain-tts", fence=older, submitted=True + ) + h.session.request_resources[(1, "still-live")] = DuplexRequestResource( + stage_id=1, request_id="still-live", fence=older, submitted=True + ) + events = await h.run(commands.BargeIn()) + assert h.port.aborts == [[request_id, "duplex-drain-tts"]] + assert not h.session.is_draining_request("duplex-drain-tts") + done_ids = [ + event.response["id"] + for event in events + if getattr(event, "wire_type", "") == "response.done" and isinstance(getattr(event, "response", None), dict) + ] + assert "resp-old" in done_ids + assert (2, "duplex-drain-tts") not in h.session.request_resources + assert (3, "duplex-drain-tts") not in h.session.request_resources + assert (0, request_id) not in h.session.request_resources + assert (1, "still-live") in h.session.request_resources + finally: + await close_harness(h) + + @pytest.mark.asyncio async def test_cancel_response_without_active_response_is_rejected() -> None: h = await open_harness() @@ -778,6 +842,46 @@ async def test_listen_decision_on_a_resumable_request_closes_the_bounded_respons await close_harness(h) +@pytest.mark.asyncio +async def test_direct_response_listen_still_emits_response_done_after_continuation_clear() -> None: + """AURA-like silent DIRECT_RESPONSE: after continuation budget is spent, emit response.done. + + Non-resumable turn-commit still closes the active response on a listen/direct + decision so the client is not left without a terminal event. + """ + from dataclasses import replace + + h = await open_harness() + try: + h.session.capabilities = replace(h.session.capabilities, supports_core_resumable_request=False) + await h.run(append_audio()) + assert h.port.submissions, "ephemeral Stage0 must submit" + request_id = h.port.submissions[-1].context.request_id + assert "-turn" in request_id + await h.deliver_and_settle(tts_output(request_id, samples=24000, text="ok")) + response_id = h.session.active_response_id + assert response_id is not None + model_state = h.runner.model_state + model_state.continuation_owner_id = f"response:{response_id}" + model_state.continuation_units = h.runner.model._AUTO_RESPONSE_MAX_CONTINUATION_UNITS + + listen = listen_output(request_id) + listen.finished = True + events = await h.deliver_and_settle( + listen, + stage_id=0, + segment_finished=True, + segment_token_ids=[11, 12, LISTEN_TOKEN_ID], + segment_output_metadata={"meta.listen_token_id": LISTEN_TOKEN_ID}, + ) + assert "response.listen" in types(events) + done = find(events, "response.done") + assert done.response_id == response_id + assert h.session.active_response_id is None + finally: + await close_harness(h) + + @pytest.mark.asyncio async def test_listen_decision_on_a_resumable_request_keeps_the_turn_going() -> None: """Same unfinished listen, budget left: it must schedule the next unit. @@ -860,6 +964,38 @@ async def test_stage_failure_fails_the_active_response() -> None: await close_harness(h) +@pytest.mark.asyncio +async def test_stage_failure_on_draining_request_fails_that_response_only() -> None: + """Overlapped: a draining older request failure must not wipe the active response.""" + from dataclasses import replace + + h = await open_harness() + try: + h.session.capabilities = replace(h.session.capabilities, supports_concurrent_turn_requests=True) + await h.run(append_audio()) + request_id = h.stage0_request_id() + await h.deliver_and_settle(tts_output(request_id, samples=24000, text="hello")) + r1 = h.session.active_response_id + assert r1 is not None + draining_req = "req-r1-talker-drain" + h.session.bind_draining_request(draining_req, r1) + # Open a newer active response while R1 TTS is still draining. + r2 = h.session.begin_response(turn_id=(h.session.turn_id or 0) + 1) + assert h.session.active_response_id == r2 + assert r2 != r1 + + h.runner.on_stage_failure(2, RuntimeError("drain-boom"), request_id=draining_req) + events = await h.settle() + assert types(events)[0] == "error" + done = find(events, "response.done") + assert done.response_id == r1 + assert done.status == "failed" + assert h.session.active_response_id == r2 + assert not h.session.is_draining_request(draining_req) + finally: + await close_harness(h) + + # --------------------------------------------------------------------------- # # Turn mode (no auto response) # # --------------------------------------------------------------------------- # @@ -877,6 +1013,91 @@ async def test_turn_mode_skips_silent_chunks() -> None: await close_harness(h) +@pytest.mark.asyncio +async def test_turn_mode_keeps_silent_chunks_with_video_frames() -> None: + """Engine vision-follow buffers silent+frames; MiniCPM commit stays speech-gated.""" + from dataclasses import replace + + h = await open_harness(auto_response=False) + try: + h.session.capabilities = replace( + h.session.capabilities, + required_input_modalities=frozenset({"video"}), + optional_input_modalities=frozenset({"audio"}), + ) + # Minimal 1x1 JPEG (base64) — wire validation only checks non-empty str. + frame = ( + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkS" + "Ew8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJ" + "CQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy" + "MjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAA" + "AAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAA" + "AAAAAAD/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGfAP/EABQQ" + "AQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAQUCf//EABQRAQAAAAAAAAAAAAAAAAAA" + "AAD/2gAIAQMBAT8Bf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Bf//E" + "ABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEABj8Cf//EABQQAQAAAAAAAAAAAAAA" + "AAAAAAD/2gAIAQEAAT8hf//Z" + ) + cmd = commands.AppendAudio( + audio=pcm_f32(160, value=0.0), + format="pcm_f32le", + sample_rate_hz=16000, + is_speech=False, + video_frames=(frame,), + ) + events = await h.run(cmd) + assert "response.listen" not in types(events) + assert h.runner.model_state.audio_buffer.has_pending() + events = await h.run(commands.Commit(create_response=True)) + # MiniCPM prepare_commit is speech-gated (no frames-only Stage0). + assert "input_audio_buffer.committed" in types(events), types(events) + assert h.port.submissions == [] + finally: + await close_harness(h) + + +@pytest.mark.asyncio +async def test_turn_mode_keeps_silent_vision_while_response_in_progress() -> None: + """TTS still playing: vision-follow must buffer, not drop as silence_or_noise.""" + from dataclasses import replace + + h = await open_harness(auto_response=False) + try: + h.session.capabilities = replace( + h.session.capabilities, + required_input_modalities=frozenset({"video"}), + optional_input_modalities=frozenset({"audio"}), + ) + h.session._response.active_response_id = "resp-tts" + frame = ( + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkS" + "Ew8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJ" + "CQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy" + "MjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAA" + "AAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAA" + "AAAAAAD/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGfAP/EABQQ" + "AQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Bf//EABQRAQAAAAAAAAAAAAAAAAAA" + "AAD/2gAIAQIBAT8Bf//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEABj8Cf//E" + "ABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAT8hf//Z" + ) + events = await h.run( + commands.AppendAudio( + audio=pcm_f32(160, value=0.0), + format="pcm_f32le", + sample_rate_hz=16000, + is_speech=False, + video_frames=(frame,), + ) + ) + listen_reasons = [ + getattr(event, "details", {}) or {} for event in events if getattr(event, "type", None) == "response.listen" + ] + assert all(details.get("reason") != "silence_or_noise" for details in listen_reasons) + assert h.runner.model_state.audio_buffer.has_pending() + finally: + await close_harness(h) + + @pytest.mark.asyncio async def test_turn_mode_commit_with_response_create_starts_one_response() -> None: h = await open_harness(auto_response=False) @@ -1033,7 +1254,7 @@ async def test_conversation_items_can_be_injected_and_deleted() -> None: await close_harness(h) -def _stage_metrics_of(event: object) -> dict[str, dict[str, object]]: +def _stage_metrics_of(event: DuplexEvent) -> dict[str, dict[str, object]]: """Per-stage engine metrics as the client reads them off one wire event.""" payload = event.to_realtime() metadata = payload.get("metadata") @@ -1081,6 +1302,40 @@ async def test_stage0_metrics_reach_the_response_even_though_its_output_feeds_tt await close_harness(h) +@pytest.mark.asyncio +async def test_projected_stage1_metrics_reach_the_spoken_audio_event() -> None: + """Thinker text is projected, but its TTFT/TPOT still have to ride the audio event.""" + h = await open_harness(stage_count=3) + try: + plugin = h.runner.model._ctx.plugin + + def project_stage1(*, stage_id: int, output: object, context: object) -> bool: + del output, context + return stage_id == 1 + + plugin.project_intermediate_output = project_stage1 + await h.run(append_audio()) + request_id = h.stage0_request_id() + thinker = SimpleNamespace( + request_id=request_id, + finished=True, + outputs=[SimpleNamespace(text="hello", token_ids=[7, 8], multimodal_output={})], + multimodal_output={}, + ) + forwarded = h.deliver( + thinker, + stage_id=1, + segment_finished=False, + metrics=stage_stats(stage_id=1, request_id=request_id, num_tokens_out=2, itls_ms=[6.0]), + ) + assert forwarded is False + events = await h.deliver_and_settle(tts_output(request_id, samples=24000, text="hello"), stage_id=2) + stage_metrics = _stage_metrics_of(find(events, "response.output_audio.delta")) + assert stage_metrics["1"]["num_tokens_out"] == 2 + finally: + await close_harness(h) + + @pytest.mark.asyncio async def test_stage0_metrics_from_several_units_are_summed_into_one_response() -> None: """Two pass-through segments before the first audio are one response's tokens.""" diff --git a/tests/engine/duplex/test_session_runner_ephemeral_bind.py b/tests/engine/duplex/test_session_runner_ephemeral_bind.py new file mode 100644 index 000000000..cbde18ec3 --- /dev/null +++ b/tests/engine/duplex/test_session_runner_ephemeral_bind.py @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Ephemeral Stage0 bind uses r.stage0-turn{N}, not resumable r.stage0.""" + +from __future__ import annotations + +from vllm_omni.engine.duplex.contracts import ( + DuplexFence, + duplex_ephemeral_stage_request_id, + duplex_resource_request_id, +) +from vllm_omni.engine.duplex.session.manager import DuplexSessionManager + + +def test_stage0_request_id_ephemeral_when_not_resumable() -> None: + fence = DuplexFence("duplex-test", epoch=0, turn_id=3) + rid = DuplexSessionManager.stage_request_id(fence, stage_id=0, resumable=False) + expected = duplex_ephemeral_stage_request_id(fence, stage_id=0) + assert rid == expected + assert rid.endswith(".r.stage0-turn3") + assert not rid.endswith(".r.stage0") + + +def test_stage0_request_id_resumable_keeps_stable_role() -> None: + fence = DuplexFence("duplex-test", epoch=1, turn_id=3) + rid = DuplexSessionManager.stage_request_id(fence, stage_id=0, resumable=True) + expected = duplex_resource_request_id(fence, "stage0") + assert rid == expected + assert rid.endswith(".r.stage0") diff --git a/tests/engine/test_duplex_orchestrator.py b/tests/engine/test_duplex_orchestrator.py index 703af6a35..1bffda919 100644 --- a/tests/engine/test_duplex_orchestrator.py +++ b/tests/engine/test_duplex_orchestrator.py @@ -55,6 +55,7 @@ def _build( stages: int = 1, running_counter: FakeRunningCounter | None = None, runtime_config: DuplexSessionRuntimeConfig | None = None, + plugin=None, ) -> tuple[DuplexOrchestrator, list[FakeStageClient], asyncio.Queue, asyncio.Queue]: clients = [FakeStageClient(stage_type="llm", final_output=index == stages - 1) for index in range(stages)] pools = _build_stage_pools( @@ -70,7 +71,7 @@ def _build( rpc_async_queue=rpc_q, stage_pools=pools, running_counter=running_counter, - plugin=MiniCPMO45DuplexPlugin(_encode_audio), + plugin=plugin or MiniCPMO45DuplexPlugin(_encode_audio), duplex_session_config=runtime_config or DuplexSessionRuntimeConfig(reaper_interval_s=0.01), model_config=None, ) @@ -197,7 +198,8 @@ async def test_open_preregisters_the_stage0_request_and_close_releases_it() -> N assert request_state.session_owned is True assert request_state.session_id == SESSION_ID assert request_state.fence == DuplexFence(SESSION_ID) - assert request_state.streaming.enabled is True + # Set on submit from submission.resumable, not again at preregister. + assert request_state.streaming.enabled is False # Preregistration reserves the id; nothing is running until an append submits. assert counter.value == 0 assert clients[0].add_request_calls == [] @@ -211,6 +213,18 @@ async def test_open_preregisters_the_stage0_request_and_close_releases_it() -> N assert orchestrator.session_manager.active_count() == 0 +@pytest.mark.asyncio +async def test_aura_ephemeral_preregister_disables_streaming() -> None: + from vllm_omni.model_executor.models.aura_omni.duplex.plugin import AuraDuplexPlugin + + orchestrator, _, rpc_q, _ = _build(plugin=AuraDuplexPlugin(_encode_audio)) + result = await _open(orchestrator, rpc_q) + assert result.ok is True + request_state = next(iter(orchestrator.request_states.values())) + assert request_state.streaming.enabled is False + await orchestrator.session_manager.shutdown() + + @pytest.mark.asyncio async def test_open_failure_rolls_back_session_and_reserved_request() -> None: orchestrator, _, rpc_q, _ = _build() @@ -275,6 +289,7 @@ async def test_append_submits_the_resumable_stage0_request_and_counts_it_running assert submitted.resumable is True assert submitted.sampling_params.max_tokens == 20 request_state = orchestrator.request_states[request_id] + assert request_state.streaming.enabled is True assert request_state.stage_fences[0] == DuplexFence(SESSION_ID) assert 0 in request_state.stage_submit_ts assert counter.value == 1 @@ -390,6 +405,31 @@ async def test_forward_failure_closes_the_owning_session() -> None: assert types[-1] == "session.expired" +@pytest.mark.asyncio +async def test_aura_forward_failure_keeps_the_session() -> None: + from vllm_omni.model_executor.models.aura_omni.duplex.plugin import AuraDuplexPlugin + + orchestrator, clients, rpc_q, output_q = _build(stages=2, plugin=AuraDuplexPlugin(_encode_audio)) + await _open(orchestrator, rpc_q) + request_id = next(iter(orchestrator.request_states)) + await _submit(orchestrator, _append_audio()) + request_state = orchestrator.request_states[request_id] + session = orchestrator.session_manager.get(SESSION_ID) + assert session is not None + + absorbed = await orchestrator._handle_forward_failure(request_id, 1, request_state, ValueError("stale talker")) + await _settle(orchestrator) + + assert absorbed is True + assert request_id not in orchestrator.request_states + assert SESSION_ID in orchestrator.session_manager.runners + assert session.state != DuplexSessionState.CLOSED + types = [message.event.type for message in [output_q.get_nowait() for _ in range(output_q.qsize())]] + assert "error" in types + assert "session.expired" not in types + await orchestrator.session_manager.shutdown() + + @pytest.mark.asyncio async def test_request_cleanup_failure_is_deferred_and_retried_by_the_reaper(monkeypatch) -> None: orchestrator, clients, rpc_q, _ = _build() @@ -560,3 +600,53 @@ async def test_turn_plugin_processes_multimodal_prompt_before_stage_submission(m assert not orchestrator.request_states[submitted.request_id].streaming.enabled finally: await _close(orchestrator, rpc_q) + + +@pytest.mark.asyncio +async def test_sentence_partial_does_not_legacy_forward_the_full_stage_output() -> None: + from vllm_omni.engine.duplex.plugin import PartialStageForward + + orchestrator, *_ = _build(stages=4) + forwarded: list[object] = [] + + async def record_forward(req_id, stage_id, output, req_state, **kwargs): + del req_id, stage_id, req_state, kwargs + forwarded.append(output) + + async def no_intercept(*args, **kwargs): + del args, kwargs + return False + + orchestrator._forward_to_next_stage = record_forward # type: ignore[method-assign] + orchestrator._intercept_stage_output = no_intercept # type: ignore[method-assign] + orchestrator.async_chunk = True + orchestrator._stage_receives_async_chunks = lambda stage_id: False # type: ignore[method-assign] + + full = SimpleNamespace(request_id="r", finished=True, text="FULL") + chunk = SimpleNamespace(request_id="r", finished=True, text="CHUNK") + req_state = DuplexOrchestratorRequestState( + request_id="r", + final_stage_id=3, + session_owned=True, + sampling_params_list=[SimpleNamespace() for _ in range(4)], + ) + orchestrator.plugin.plan_partial_stage_output = lambda *args, **kwargs: PartialStageForward( # type: ignore[method-assign] + output=chunk, is_final_update=True + ) + orchestrator.plugin.partial_stage_followup = lambda *args, **kwargs: None # type: ignore[method-assign] + + await orchestrator._route_output(1, 0, full, req_state, None) + + assert forwarded == [chunk] + assert req_state.skip_legacy_stage_forward is False + + forwarded.clear() + orchestrator.plugin.plan_partial_stage_output = lambda *args, **kwargs: None # type: ignore[method-assign] + await orchestrator._route_output(1, 0, full, req_state, None) + + assert forwarded == [full] + + +def test_default_plugin_declares_no_draining_stages() -> None: + plugin = MiniCPMO45DuplexPlugin(_encode_audio) + assert plugin.draining_stage_ids(stage_count=4) == frozenset() diff --git a/tests/engine/test_orchestrator_stage_input_bridge.py b/tests/engine/test_orchestrator_stage_input_bridge.py index 842824c2d..3c1dd6d2b 100644 --- a/tests/engine/test_orchestrator_stage_input_bridge.py +++ b/tests/engine/test_orchestrator_stage_input_bridge.py @@ -14,6 +14,7 @@ import pytest from vllm.outputs import CompletionOutput, RequestOutput from vllm.sampling_params import SamplingParams +from vllm_omni.engine.duplex_orchestrator import DuplexOrchestrator from vllm_omni.engine.orchestrator import ( Orchestrator, OrchestratorRequestState, @@ -249,6 +250,41 @@ async def test_async_prewarm_skips_outgoing_only_stage(payload_sender_info) -> N ) +@pytest.mark.asyncio +async def test_async_prewarm_skips_stage_with_custom_process_input_func() -> None: + """AURA Stage1 has asr2aura: must not be zero-prewarmed under async_chunk. + + The custom-process-input gate lives on DuplexOrchestrator (AURA path), not + the turn-based Orchestrator base. + """ + orchestrator = object.__new__(DuplexOrchestrator) + stage0 = FakePrewarmPool("sender") + stage1 = FakePrewarmPool("receiver") # would receive chunks if role alone decided + stage1.stage_client = SimpleNamespace(custom_process_input_func=lambda *a, **k: None) + stage2 = FakePrewarmPool("receiver") + orchestrator.stage_pools = [stage0, stage1, stage2] + orchestrator._emit_tx_edge = lambda **_kwargs: None + orchestrator._on_stage_submitted = MagicMock() + req_state = OrchestratorRequestState( + request_id="req-prewarm-custom", + prompt={"prompt_token_ids": [1, 2]}, + sampling_params_list=[SamplingParams(max_tokens=1) for _ in range(3)], + final_stage_id=2, + ) + + prewarmed = await orchestrator._prewarm_async_chunk_stages( + "req-prewarm-custom", + SimpleNamespace(prompt_token_ids=[1, 2], resumable=True), + req_state, + ) + + assert prewarmed is True + assert stage1.submitted == [] + assert len(stage2.submitted) == 1 + assert 1 not in req_state.stage_submit_ts + assert 2 in req_state.stage_submit_ts + + @pytest.mark.asyncio async def test_async_route_forwards_to_outgoing_only_stage() -> None: orchestrator = object.__new__(Orchestrator) diff --git a/tests/entrypoints/duplex/test_startup_warmup.py b/tests/entrypoints/duplex/test_startup_warmup.py new file mode 100644 index 000000000..0971e317d --- /dev/null +++ b/tests/entrypoints/duplex/test_startup_warmup.py @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import pybase64 as base64 + +from vllm_omni.engine.duplex.config import DuplexCapabilities +from vllm_omni.entrypoints.duplex.warmup import ( + _WARMUP_AUDIO_SECONDS, + _WARMUP_FRAME_HEIGHT, + _WARMUP_FRAME_WIDTH, + _WARMUP_SAMPLE_RATE_HZ, + _short_pcm_f32le_b64, + _warmup_jpeg_b64, + startup_warmup_kind, +) + + +class _Plugin: + def __init__(self, required: frozenset[str]) -> None: + self._required = required + + def capabilities(self, *, max_sessions: int) -> DuplexCapabilities: + del max_sessions + return DuplexCapabilities(required_input_modalities=self._required) + + +def test_video_required_model_warms_up_one_turn_by_default() -> None: + plugin = _Plugin(frozenset({"video"})) + assert startup_warmup_kind(plugin, 0) == "video_turn" + + +def test_negative_warmup_frames_disables_the_video_turn() -> None: + plugin = _Plugin(frozenset({"video"})) + assert startup_warmup_kind(plugin, -1) is None + + +def test_audio_primary_model_stays_opt_in() -> None: + plugin = _Plugin(frozenset({"audio"})) + assert startup_warmup_kind(plugin, 0) is None + assert startup_warmup_kind(plugin, 4) == "silent_frames" + + +def test_startup_turn_audio_is_short_and_not_silent() -> None: + raw = base64.b64decode(_short_pcm_f32le_b64()) + assert len(raw) == int(_WARMUP_SAMPLE_RATE_HZ * _WARMUP_AUDIO_SECONDS) * 4 + assert any(raw) + + +def test_startup_turn_frame_is_a_camera_sized_jpeg() -> None: + from io import BytesIO + + from PIL import Image + + image = Image.open(BytesIO(base64.b64decode(_warmup_jpeg_b64()))) + assert image.size == (_WARMUP_FRAME_WIDTH, _WARMUP_FRAME_HEIGHT) + assert image.format == "JPEG" diff --git a/tests/examples/test_minicpmo_realtime_web_server.py b/tests/examples/test_minicpmo_realtime_web_server.py index 44f04eb1b..0f7149a34 100644 --- a/tests/examples/test_minicpmo_realtime_web_server.py +++ b/tests/examples/test_minicpmo_realtime_web_server.py @@ -113,12 +113,23 @@ def test_qwen_config_does_not_inject_reference_voice(): assert '"profile": "qwen3-turn"' in page assert '"adapter": "vad"' in page assert '"refAudio": null' in page - for name in ("common", "minicpm_native", "qwen3_turn"): + for name in ("common", "minicpm_native", "qwen3_turn", "aura_ptt"): assert client.get(f"/static/profiles/{name}.js").status_code == 200 with pytest.raises(ValueError, match="ref-audio"): build_app(profile="qwen3-turn", ref_audio="unused.wav") +def test_aura_ptt_config_defaults_and_rejects_ref_audio(): + client = TestClient(build_app(profile="aura-ptt", model="aurateam/AURA")) + page = client.get("/").text + assert '"profile": "aura-ptt"' in page + assert '"refAudio": null' in page + assert "aura_ptt.js" in page + assert 'id="pttButton"' in page + with pytest.raises(ValueError, match="ref-audio"): + build_app(profile="aura-ptt", ref_audio="unused.wav") + + def test_config_cannot_close_its_script_element(): page = TestClient(build_app(model="")).get("/").text assert "