Merge remote-tracking branch 'origin/main' into qwen-image-2.1
# Conflicts: # vllm_omni/diffusion/executor/multiproc_executor.py # vllm_omni/diffusion/offloader/sequential_backend.py
This commit is contained in:
commit
9174554e40
1332 files changed
+160051
-60504
No files matched your search
@@ -25,7 +25,7 @@ from vllm.utils.mem_utils import DeviceMemoryProfiler, GiB_bytes
|
||||
from vllm.v1.kv_cache_interface import KVCacheConfig, KVCacheSpec
|
||||
|
||||
from vllm_omni.diffusion.attention.layer import Attention
|
||||
from vllm_omni.diffusion.cache.cachedit import cache_summary
|
||||
from vllm_omni.diffusion.cache.cachedit import CacheDiTBackend, cache_summary
|
||||
from vllm_omni.diffusion.cache.prompt_embed_cache import (
|
||||
install_prompt_embed_cache,
|
||||
resolve_prompt_embed_cache_config,
|
||||
@@ -43,6 +43,7 @@ from vllm_omni.diffusion.diffusion_kv.paged_attention_adapter import (
|
||||
from vllm_omni.diffusion.distributed.parallel_state import get_classifier_free_guidance_rank
|
||||
from vllm_omni.diffusion.forward_context import set_forward_context
|
||||
from vllm_omni.diffusion.interaction.coordinator import InteractionCoordinator
|
||||
from vllm_omni.diffusion.interaction.types import InteractionPayload
|
||||
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
|
||||
from vllm_omni.diffusion.models.interface import (
|
||||
SupportsInteractionApply,
|
||||
@@ -53,6 +54,7 @@ from vllm_omni.diffusion.models.interface import (
|
||||
)
|
||||
from vllm_omni.diffusion.offloader import enable_offload_backend
|
||||
from vllm_omni.diffusion.offloader.config import TEXT_ENCODER_COMPONENT, resolve_offload
|
||||
from vllm_omni.diffusion.postprocess.device_reduction import prepare_diffusion_media_for_transport
|
||||
from vllm_omni.diffusion.registry import _NO_CACHE_ACCELERATION
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.sched.interface import (
|
||||
@@ -192,14 +194,20 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
self.state_cache: dict[str, StepRequestState] = {}
|
||||
|
||||
# Initialize KV cache manager for connector management.
|
||||
self.kv_transfer_manager = OmniKVTransferManager.from_od_config(od_config)
|
||||
self.kv_transfer_manager = (
|
||||
OmniKVTransferManager.from_od_config(od_config)
|
||||
if getattr(od_config, "kv_transfer_config", None) is None
|
||||
else None
|
||||
)
|
||||
self._kv_connector = None
|
||||
|
||||
# Prefetch covers TP / SP / CFG-Parallel / HSDP. Disabled when a CFG
|
||||
# companion KV collector is set (that KV is not backgrounded).
|
||||
has_cfg_companion_kv = getattr(od_config, "cfg_kv_collect_func", None) is not None
|
||||
|
||||
self._kv_prefetch_enabled = (
|
||||
bool(self.kv_transfer_manager.config.enable_kv_async_prefetch)
|
||||
self.kv_transfer_manager is not None
|
||||
and bool(self.kv_transfer_manager.config.enable_kv_async_prefetch)
|
||||
and not has_cfg_companion_kv
|
||||
and self.kv_transfer_manager.config.need_recv_cache
|
||||
)
|
||||
@@ -412,7 +420,7 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
# may explicitly adopt the enabled Cache-DiT backend and then
|
||||
# own all later request-boundary enable/disable transitions.
|
||||
self.cache_backend.enable(self.pipeline)
|
||||
if str(self.od_config.cache_backend).lower() == "cache_dit" and adopt_request_scoped_cache_dit(
|
||||
if isinstance(self.cache_backend, CacheDiTBackend) and adopt_request_scoped_cache_dit(
|
||||
self.pipeline,
|
||||
self.cache_backend,
|
||||
):
|
||||
@@ -474,6 +482,16 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
|
||||
self.diffusion_kv_backend.initialize_kv_cache(kv_cache_config)
|
||||
self.kv_cache_config = self.diffusion_kv_backend.kv_cache_config
|
||||
if getattr(self.od_config, "kv_transfer_config", None) is not None:
|
||||
from vllm.v1.worker.gpu.kv_connector import ActiveKVConnector
|
||||
|
||||
self._kv_connector = ActiveKVConnector(self.vllm_config, self.diffusion_kv_backend.kv_caches_by_layer)
|
||||
|
||||
def prepare_kv_for_forward(self, scheduler_output: DiffusionSchedulerOutput):
|
||||
from vllm_omni.diffusion.diffusion_kv.kv_connector import wait_for_kv_load
|
||||
|
||||
timeout = self.od_config.kv_transfer_config.kv_connector_extra_config.get("transfer_timeout", 60.0)
|
||||
return wait_for_kv_load(self._kv_connector, scheduler_output, timeout)
|
||||
|
||||
def install_diffusion_kv_metadata(self, metadata: DiffusionKVMetadata) -> bool:
|
||||
return self.diffusion_kv_backend.install_diffusion_kv_metadata(metadata)
|
||||
@@ -486,7 +504,7 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
) -> int:
|
||||
return self.diffusion_kv_backend.get_diffusion_kv_row(request_id, sequence_id, context_id)
|
||||
|
||||
def remove_diffusion_kv_requests(self, request_ids: list[str]) -> int:
|
||||
def remove_diffusion_kv_requests(self, request_ids: list[str | tuple[str, int]]) -> int:
|
||||
return self.diffusion_kv_backend.remove_diffusion_kv_requests(request_ids)
|
||||
|
||||
def refresh_diffusion_kv_block_table_layout(self) -> None:
|
||||
@@ -523,8 +541,9 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
DiffusionPagedAttentionRow(
|
||||
request_id=request_metadata.request_id,
|
||||
sequence_id=sequence.sequence_id,
|
||||
query_len=sequence.seq_len,
|
||||
query_len=sequence.seq_len - sequence.num_computed_tokens,
|
||||
seq_len=sequence.seq_len,
|
||||
kv_start_pos=sequence.num_computed_tokens,
|
||||
)
|
||||
)
|
||||
denoise_rows.append(
|
||||
@@ -609,6 +628,9 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
kv_prefetch_job: KVPrefetchJob | None = None,
|
||||
use_prefetch: bool = False,
|
||||
) -> None:
|
||||
if self.kv_transfer_manager is None:
|
||||
self._initialize_generator(req.sampling_params)
|
||||
return
|
||||
# Receive AR KV. Single-request execution can use the prefetch path:
|
||||
# consume prior-forward payload, sync-fallback on miss; request-batch
|
||||
# execution keeps the synchronous per-request receive path.
|
||||
@@ -666,12 +688,13 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
num_inference_steps = getattr(self.pipeline, "default_num_inference_steps", None)
|
||||
if num_inference_steps is None and od_config.cache_backend in (
|
||||
"tea_cache",
|
||||
"sea_cache",
|
||||
"step_cache",
|
||||
):
|
||||
# When num_inference_steps is None, some pipelines defer to their
|
||||
# own defaults. TeaCache refresh ignores this value; step_cache
|
||||
# refresh is a no-op because per-chunk state resets in the denoise
|
||||
# loop. Use the pipeline default when available to keep refresh
|
||||
# own defaults. These backends use refresh to reset request state;
|
||||
# runtime step metadata is either unused or resolved in the
|
||||
# pipeline. Use the pipeline default when available to keep refresh
|
||||
# behavior aligned with single-request execution.
|
||||
num_inference_steps = getattr(self.pipeline, "num_inference_steps", 0) or 0
|
||||
|
||||
@@ -684,6 +707,21 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
od_config.cache_backend,
|
||||
)
|
||||
|
||||
def _prepare_output_for_transport(
|
||||
self,
|
||||
output: DiffusionOutput,
|
||||
sampling_params: OmniDiffusionSamplingParams,
|
||||
) -> DiffusionOutput:
|
||||
if output.media is not None:
|
||||
if output.output is not None:
|
||||
raise ValueError("DiffusionOutput cannot contain both media and legacy output")
|
||||
output.media = prepare_diffusion_media_for_transport(
|
||||
output.media,
|
||||
od_config=self.od_config,
|
||||
sampling_params=sampling_params,
|
||||
)
|
||||
return output
|
||||
|
||||
def _runner_output_from_outputs(
|
||||
self,
|
||||
reqs: list[OmniDiffusionRequest],
|
||||
@@ -757,6 +795,8 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
"Diffusion KV metadata count must match the request batch: "
|
||||
f"metadata={len(diffusion_kv_metadata)}, requests={len(reqs)}"
|
||||
)
|
||||
for req, metadata in zip(reqs, diffusion_kv_metadata, strict=True):
|
||||
req.kv_computed_tokens = tuple(seq.num_computed_tokens for seq in metadata.sequences)
|
||||
paged_metadata = self._build_paged_attention_metadata(diffusion_kv_metadata)
|
||||
paged_kv_runtime, paged_kv_context = self.diffusion_kv_backend.activate_paged_attention_metadata(
|
||||
paged_metadata
|
||||
@@ -778,6 +818,11 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
allow_single_output=allow_single_output,
|
||||
pipeline_name=type(self.pipeline).__name__,
|
||||
)
|
||||
with record_function("prepare_output_for_transport"):
|
||||
outputs = [
|
||||
self._prepare_output_for_transport(output, req.sampling_params)
|
||||
for req, output in zip(reqs, outputs, strict=True)
|
||||
]
|
||||
|
||||
if is_primary and outputs and record_output_peak_memory:
|
||||
batch_peak_memory_mb = self._sample_peak_memory_mb()
|
||||
@@ -1000,13 +1045,18 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
kv_sender_info=sched_new_req.req.kv_sender_info,
|
||||
prepared_layout=getattr(sched_new_req.req, "prepared_layout", None),
|
||||
)
|
||||
if sched_new_req.diffusion_kv_metadata is not None:
|
||||
new_state.extra["kv_computed_tokens"] = tuple(
|
||||
seq.num_computed_tokens for seq in sched_new_req.diffusion_kv_metadata.sequences
|
||||
)
|
||||
state_req = copy.copy(sched_new_req.req)
|
||||
state_req.sampling_params = new_state.sampling
|
||||
self.kv_transfer_manager.receive_multi_kv_cache_distributed(
|
||||
state_req,
|
||||
cfg_kv_collect_func=getattr(self.od_config, "cfg_kv_collect_func", None),
|
||||
target_device=self._target_device,
|
||||
)
|
||||
if self.kv_transfer_manager is not None:
|
||||
self.kv_transfer_manager.receive_multi_kv_cache_distributed(
|
||||
state_req,
|
||||
cfg_kv_collect_func=getattr(self.od_config, "cfg_kv_collect_func", None),
|
||||
target_device=self._target_device,
|
||||
)
|
||||
self.state_cache[request_id] = new_state
|
||||
resolved.append(new_state)
|
||||
|
||||
@@ -1029,34 +1079,19 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
new_request_ids: list[str],
|
||||
) -> tuple[list[StepRequestState], InputBatch | None, list[RunnerOutput]]:
|
||||
# process new reqs
|
||||
pipeline = self.pipeline
|
||||
assert pipeline is not None, "Model not loaded. Call load_model() first."
|
||||
prepared_states: list[StepRequestState] = []
|
||||
error_outputs: list[RunnerOutput] = []
|
||||
for state in states:
|
||||
if state.request_id in new_request_ids:
|
||||
# Everything that runs before ``_dit_any_rank_failed`` must be
|
||||
# inside the try: an exception in ``_initialize_generator`` or
|
||||
# Everything that requires rank-synchronization must be called
|
||||
# inside a try, record the exception and handle with `_dit_any_rank_failed`.
|
||||
# Reason (example): An exception in ``_initialize_generator`` or
|
||||
# ``clear_pipeline_stage_durations`` on one rank would skip the
|
||||
# all-reduce here while every peer proceeds into it, and the
|
||||
# peers then hang on the NCCL collective until timeout.
|
||||
per_req_exc: BaseException | None = None
|
||||
try:
|
||||
self._initialize_generator(state.sampling)
|
||||
clear_pipeline_stage_durations(self.pipeline)
|
||||
# encode
|
||||
self.pipeline.prepare_encode(state)
|
||||
merge_stage_durations(
|
||||
state,
|
||||
consume_pipeline_stage_durations(self.pipeline),
|
||||
)
|
||||
except Exception as exc:
|
||||
per_req_exc = exc
|
||||
# Pipelines that do rank-0-only work (e.g. MiniMax H3
|
||||
# reference-video prep) must broadcast per-request failures
|
||||
# internally so downstream collectives stay in step; even so,
|
||||
# cross-check that every DiT rank agrees so a rank-local error
|
||||
# (or a future pipeline that omits the guard) does not leave
|
||||
# the process group half-way through a new request.
|
||||
if _dit_any_rank_failed(per_req_exc is not None):
|
||||
def _abort_prep_failure(per_req_exc: BaseException | None) -> None:
|
||||
self.state_cache.pop(state.request_id, None)
|
||||
if per_req_exc is None:
|
||||
per_req_exc = RuntimeError(
|
||||
@@ -1076,6 +1111,41 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
result=DiffusionOutput.from_exception(per_req_exc),
|
||||
)
|
||||
)
|
||||
|
||||
per_req_exc: BaseException | None = None
|
||||
try:
|
||||
self._initialize_generator(state.sampling)
|
||||
clear_pipeline_stage_durations(pipeline)
|
||||
pipeline.prepare_encode(state)
|
||||
except Exception as exc:
|
||||
per_req_exc = exc
|
||||
# Pipelines that do rank-0-only work (e.g. MiniMax H3
|
||||
# reference-video prep) must broadcast per-request failures
|
||||
# internally so downstream collectives stay in step; even so,
|
||||
# cross-check that every DiT rank agrees so a rank-local error
|
||||
# (or a future pipeline that omits the guard) does not leave
|
||||
# the process group half-way through a new request.
|
||||
if _dit_any_rank_failed(per_req_exc is not None):
|
||||
_abort_prep_failure(per_req_exc)
|
||||
continue
|
||||
# If the pipeline supports interaction, the interaction session initialization also needs to call
|
||||
# synchronized_monotonic_time(). Wrap in another try-block to not block on prepare_encode failures.
|
||||
try:
|
||||
if supports_interaction_apply(pipeline) and state.chunk_index == 0:
|
||||
pipe = cast(SupportsInteractionApply, pipeline)
|
||||
assert self._interaction_coordinator is not None, "Model not loaded. Call load_model() first."
|
||||
state.interaction_chunk_metadata = self._interaction_coordinator.maybe_prepare_initial_session(
|
||||
state, pipe
|
||||
)
|
||||
pipe.prepare_next_chunk(state)
|
||||
merge_stage_durations(
|
||||
state,
|
||||
consume_pipeline_stage_durations(pipeline),
|
||||
)
|
||||
except Exception as exc:
|
||||
per_req_exc = exc
|
||||
if _dit_any_rank_failed(per_req_exc is not None):
|
||||
_abort_prep_failure(per_req_exc)
|
||||
continue
|
||||
prepared_states.append(state)
|
||||
|
||||
@@ -1230,6 +1300,8 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
use_hsdp = self.od_config.parallel_config.use_hsdp
|
||||
grad_context = torch.no_grad() if use_hsdp else torch.inference_mode()
|
||||
with grad_context:
|
||||
pipeline = self.pipeline
|
||||
assert pipeline is not None, "Model not loaded. Call load_model() first."
|
||||
had_active_states = bool(self.state_cache)
|
||||
states, new_request_ids = self._update_states(scheduler_output)
|
||||
is_primary = not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0
|
||||
@@ -1244,7 +1316,7 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
states, input_batch, runner_output_list = self._prepare_batch_inputs(states, new_request_ids)
|
||||
if input_batch is None:
|
||||
return BatchRunnerOutput.from_list(runner_output_list)
|
||||
attn_metadata = {}
|
||||
attn_metadata: dict[str, Any] = {}
|
||||
|
||||
kv_backend = getattr(self, "diffusion_kv_backend", None)
|
||||
paged_kv_runtime = kv_backend if getattr(kv_backend, "paged_attention_adapter", None) is not None else None
|
||||
@@ -1255,16 +1327,16 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
paged_kv_runtime=paged_kv_runtime,
|
||||
in_diffusion_kv_memory_profile=in_diffusion_kv_memory_profile,
|
||||
):
|
||||
clear_pipeline_stage_durations(self.pipeline)
|
||||
noise_pred = self.pipeline.denoise_step(input_batch, states=states)
|
||||
denoise_stage_durations = consume_pipeline_stage_durations(self.pipeline)
|
||||
clear_pipeline_stage_durations(pipeline)
|
||||
noise_pred = pipeline.denoise_step(input_batch, states=states)
|
||||
denoise_stage_durations = consume_pipeline_stage_durations(pipeline)
|
||||
for state in states:
|
||||
merge_stage_durations(
|
||||
state,
|
||||
denoise_stage_durations,
|
||||
)
|
||||
|
||||
pipeline_interrupted = getattr(self.pipeline, "interrupt", False)
|
||||
pipeline_interrupted = getattr(pipeline, "interrupt", False)
|
||||
if noise_pred is None and pipeline_interrupted:
|
||||
for state in states:
|
||||
runner_output_list.append(
|
||||
@@ -1279,9 +1351,11 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
else:
|
||||
offset = 0
|
||||
for req in states:
|
||||
if req.latents is None:
|
||||
raise RuntimeError(f"Stepwise request {req.request_id} has no latent state.")
|
||||
row_num = req.latents.shape[0]
|
||||
try:
|
||||
self.pipeline.step_scheduler(
|
||||
pipeline.step_scheduler(
|
||||
req, noise_pred[offset : offset + row_num] if noise_pred is not None else None
|
||||
)
|
||||
if self.od_config.streaming_output:
|
||||
@@ -1290,17 +1364,18 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
should_decode = req.denoise_completed
|
||||
|
||||
if should_decode:
|
||||
clear_pipeline_stage_durations(self.pipeline)
|
||||
result = self.pipeline.post_decode(req)
|
||||
clear_pipeline_stage_durations(pipeline)
|
||||
result = pipeline.post_decode(req)
|
||||
if result is not None:
|
||||
result = self._prepare_output_for_transport(result, req.sampling)
|
||||
self._attach_stepwise_metadata(
|
||||
req,
|
||||
result,
|
||||
)
|
||||
# After consuming this chunk's interaction metadata, apply pending interactions and
|
||||
# prepare the next chunk (prepare_next_chunk may be a no-op---depending on pipeline)
|
||||
if supports_interaction_apply(self.pipeline) and not req.request_denoise_completed:
|
||||
pipe = cast(SupportsInteractionApply, self.pipeline)
|
||||
if supports_interaction_apply(pipeline) and not req.request_denoise_completed:
|
||||
pipe = cast(SupportsInteractionApply, pipeline)
|
||||
pipe.apply_interaction_at_chunk_boundary(req)
|
||||
pipe.prepare_next_chunk(req)
|
||||
else:
|
||||
@@ -1352,12 +1427,12 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
for runner_output in runner_output_list:
|
||||
if runner_output.result is None:
|
||||
continue
|
||||
state = states_by_id.get(runner_output.request_id)
|
||||
if state is None:
|
||||
matched_state = states_by_id.get(runner_output.request_id)
|
||||
if matched_state is None:
|
||||
continue
|
||||
runner_output.result.peak_memory_mb = max(
|
||||
runner_output.result.peak_memory_mb,
|
||||
state.peak_memory_mb,
|
||||
matched_state.peak_memory_mb,
|
||||
)
|
||||
|
||||
terminal_request_ids = [
|
||||
@@ -1379,47 +1454,33 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
|
||||
interaction: OmniInteractionPrompt,
|
||||
) -> None:
|
||||
"""Route a midway interaction through the pipeline interaction coordinator."""
|
||||
assert self.pipeline is not None, "Model not loaded. Call load_model() first."
|
||||
assert self.pipeline is not None and self._interaction_coordinator is not None, (
|
||||
"Model not loaded. Call load_model() first."
|
||||
)
|
||||
if not self.od_config.streaming_output:
|
||||
raise ValueError("submit_interaction requires streaming_output=True")
|
||||
if not self._supports_step_mode():
|
||||
raise ValueError("submit_interaction requires step execution support")
|
||||
|
||||
coordinator = self._interaction_coordinator
|
||||
if coordinator is None:
|
||||
coordinator = InteractionCoordinator.build(self.pipeline, self.od_config)
|
||||
self._interaction_coordinator = coordinator
|
||||
if hasattr(self.pipeline, "_interaction_coordinator"):
|
||||
self.pipeline._interaction_coordinator = coordinator
|
||||
|
||||
event = interaction.get("event")
|
||||
has_mm = isinstance(event, dict) and "multi_modal_data" in event
|
||||
has_prompt = isinstance(event, dict) and "prompt" in event and event.get("prompt") is not None
|
||||
|
||||
# Prompt-only interactions in this release; multi_modal_data lands with camera support.
|
||||
if not isinstance(event, dict) or has_mm or not has_prompt:
|
||||
raise NotImplementedError(
|
||||
"Only text-only prompt update interactions with 'event.prompt' and optional "
|
||||
"'transition_chunks' are supported in this release"
|
||||
)
|
||||
if not coordinator.has_modality("prompt"):
|
||||
raise ValueError(f"prompt_update is not supported by pipeline {self.od_config.model_class_name!r}")
|
||||
|
||||
state = self.state_cache.get(request_id)
|
||||
if state is None:
|
||||
raise ValueError(f"No active request state for interaction: {request_id!r}")
|
||||
|
||||
event_id = interaction.get("event_id")
|
||||
if not isinstance(event_id, str) or not event_id:
|
||||
raise ValueError("event_id must be non-empty")
|
||||
prompt = event["prompt"]
|
||||
if not isinstance(prompt, str) or not prompt:
|
||||
raise ValueError("prompt must be non-empty")
|
||||
coordinator.enqueue(
|
||||
event = interaction["event"]
|
||||
parts: list[tuple[str, InteractionPayload]] = []
|
||||
prompt = event.get("prompt")
|
||||
if prompt is not None:
|
||||
parts.append(("prompt", {"prompt": prompt}))
|
||||
multi_modal_data = event.get("multi_modal_data")
|
||||
if multi_modal_data:
|
||||
parts.extend(
|
||||
(str(modality), cast(InteractionPayload, payload)) for modality, payload in multi_modal_data.items()
|
||||
)
|
||||
|
||||
self._interaction_coordinator.enqueue_parts(
|
||||
state,
|
||||
modality="prompt",
|
||||
event_id=event_id,
|
||||
parts=parts,
|
||||
event_id=interaction["event_id"],
|
||||
received_at=time.monotonic(),
|
||||
payload={"prompt": prompt},
|
||||
transition_chunks=interaction.get("transition_chunks"),
|
||||
)
|
||||
Reference in new issue
Block a user