mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-01-27 06:40:16 +08:00
Fix tests
Merge branch 'master' of github.com:comfyanonymous/ComfyUI
This commit is contained in:
commit
f6d3962c77
@ -1,6 +1,6 @@
|
|||||||
# This file is automatically generated by the build process when version is
|
# This file is automatically generated by the build process when version is
|
||||||
# updated in pyproject.toml.
|
# updated in pyproject.toml.
|
||||||
__version__ = "0.3.59"
|
__version__ = "0.3.60"
|
||||||
|
|
||||||
# This deals with workspace issues
|
# This deals with workspace issues
|
||||||
from comfy_compatibility.workspace import auto_patch_workspace_and_restart
|
from comfy_compatibility.workspace import auto_patch_workspace_and_restart
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import concurrent.futures
|
||||||
import copy
|
import copy
|
||||||
import gc
|
import gc
|
||||||
import json
|
import json
|
||||||
@ -23,7 +24,9 @@ from ..cli_args_types import Configuration
|
|||||||
from ..cmd.folder_paths import init_default_paths # pylint: disable=import-error
|
from ..cmd.folder_paths import init_default_paths # pylint: disable=import-error
|
||||||
from ..component_model.executor_types import ExecutorToClientProgress
|
from ..component_model.executor_types import ExecutorToClientProgress
|
||||||
from ..component_model.make_mutable import make_mutable
|
from ..component_model.make_mutable import make_mutable
|
||||||
|
from ..component_model.queue_types import QueueItem, ExecutionStatus, TaskInvocation
|
||||||
from ..distributed.executors import ContextVarExecutor
|
from ..distributed.executors import ContextVarExecutor
|
||||||
|
from ..distributed.history import History
|
||||||
from ..distributed.process_pool_executor import ProcessPoolExecutor
|
from ..distributed.process_pool_executor import ProcessPoolExecutor
|
||||||
from ..distributed.server_stub import ServerStub
|
from ..distributed.server_stub import ServerStub
|
||||||
from ..execution_context import current_execution_context
|
from ..execution_context import current_execution_context
|
||||||
@ -168,6 +171,7 @@ class Comfy:
|
|||||||
self._is_running = False
|
self._is_running = False
|
||||||
self._task_count_lock = RLock()
|
self._task_count_lock = RLock()
|
||||||
self._task_count = 0
|
self._task_count = 0
|
||||||
|
self._history = History()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
@ -181,6 +185,10 @@ class Comfy:
|
|||||||
self._is_running = True
|
self._is_running = True
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def history(self) -> History:
|
||||||
|
return self._history
|
||||||
|
|
||||||
def __exit__(self, *args):
|
def __exit__(self, *args):
|
||||||
get_event_loop().run_in_executor(self._executor, _cleanup)
|
get_event_loop().run_in_executor(self._executor, _cleanup)
|
||||||
self._executor.shutdown(wait=True)
|
self._executor.shutdown(wait=True)
|
||||||
@ -251,15 +259,19 @@ class Comfy:
|
|||||||
with self._task_count_lock:
|
with self._task_count_lock:
|
||||||
self._task_count += 1
|
self._task_count += 1
|
||||||
prompt_id = prompt_id or str(uuid.uuid4())
|
prompt_id = prompt_id or str(uuid.uuid4())
|
||||||
|
assert prompt_id is not None
|
||||||
client_id = client_id or self._progress_handler.client_id or None
|
client_id = client_id or self._progress_handler.client_id or None
|
||||||
span_context = context.get_current()
|
span_context = context.get_current()
|
||||||
carrier = {}
|
carrier = {}
|
||||||
propagate.inject(carrier, span_context)
|
propagate.inject(carrier, span_context)
|
||||||
|
# setup history
|
||||||
|
prompt = make_mutable(prompt)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await get_event_loop().run_in_executor(
|
outputs = await get_event_loop().run_in_executor(
|
||||||
self._executor,
|
self._executor,
|
||||||
_execute_prompt,
|
_execute_prompt,
|
||||||
make_mutable(prompt),
|
prompt,
|
||||||
prompt_id,
|
prompt_id,
|
||||||
client_id,
|
client_id,
|
||||||
carrier,
|
carrier,
|
||||||
@ -268,6 +280,16 @@ class Comfy:
|
|||||||
self._configuration,
|
self._configuration,
|
||||||
partial_execution_targets,
|
partial_execution_targets,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fut = concurrent.futures.Future()
|
||||||
|
fut.set_result(TaskInvocation(prompt_id, copy.deepcopy(outputs), ExecutionStatus('success', True, [])))
|
||||||
|
self._history.put(QueueItem(queue_tuple=(float(self._task_count), prompt_id, prompt, {}, []), completed=fut), outputs, ExecutionStatus('success', True, []))
|
||||||
|
return outputs
|
||||||
|
except Exception as exc_info:
|
||||||
|
fut = concurrent.futures.Future()
|
||||||
|
fut.set_exception(exc_info)
|
||||||
|
self._history.put(QueueItem(queue_tuple=(float(self._task_count), prompt_id, prompt, {}, []), completed=fut), {}, ExecutionStatus('error', False, [str(exc_info)]))
|
||||||
|
raise exc_info
|
||||||
finally:
|
finally:
|
||||||
with self._task_count_lock:
|
with self._task_count_lock:
|
||||||
self._task_count -= 1
|
self._task_count -= 1
|
||||||
|
|||||||
@ -684,7 +684,14 @@ class PromptServer(ExecutorToClientProgress):
|
|||||||
max_items = request.rel_url.query.get("max_items", None)
|
max_items = request.rel_url.query.get("max_items", None)
|
||||||
if max_items is not None:
|
if max_items is not None:
|
||||||
max_items = int(max_items)
|
max_items = int(max_items)
|
||||||
return web.json_response(self.prompt_queue.get_history(max_items=max_items))
|
|
||||||
|
offset = request.rel_url.query.get("offset", None)
|
||||||
|
if offset is not None:
|
||||||
|
offset = int(offset)
|
||||||
|
else:
|
||||||
|
offset = -1
|
||||||
|
|
||||||
|
return web.json_response(self.prompt_queue.get_history(max_items=max_items, offset=offset))
|
||||||
|
|
||||||
@routes.get("/history/{prompt_id}")
|
@routes.get("/history/{prompt_id}")
|
||||||
async def get_history_prompt_id(request):
|
async def get_history_prompt_id(request):
|
||||||
|
|||||||
@ -9,19 +9,20 @@ from ..component_model.queue_types import HistoryEntry, QueueItem, ExecutionStat
|
|||||||
|
|
||||||
|
|
||||||
class History:
|
class History:
|
||||||
def __init__(self):
|
def __init__(self, maximum_history_size=MAXIMUM_HISTORY_SIZE):
|
||||||
self.history: typing.OrderedDict[str, HistoryEntry] = collections.OrderedDict()
|
self.history: typing.OrderedDict[str, HistoryEntry] = collections.OrderedDict()
|
||||||
|
self.maximum_history_size = maximum_history_size
|
||||||
|
|
||||||
def put(self, queue_item: QueueItem, outputs: dict, status: ExecutionStatus):
|
def put(self, queue_item: QueueItem, outputs: dict, status: ExecutionStatus):
|
||||||
self.history[queue_item.prompt_id] = HistoryEntry(prompt=queue_item.queue_tuple,
|
self.history[queue_item.prompt_id] = HistoryEntry(prompt=queue_item.queue_tuple,
|
||||||
outputs=outputs,
|
outputs=outputs,
|
||||||
status=ExecutionStatus(*status)._asdict())
|
status=ExecutionStatus(*status).as_dict())
|
||||||
|
|
||||||
def copy(self, prompt_id: typing.Optional[str | int] = None, max_items: typing.Optional[int] = None,
|
def copy(self, prompt_id: typing.Optional[str | int] = None, max_items: typing.Optional[int] = None,
|
||||||
offset: typing.Optional[int] = None) -> dict[str, HistoryEntry]:
|
offset: typing.Optional[int] = None) -> dict[str, HistoryEntry]:
|
||||||
if offset is not None and offset < 0:
|
if offset is not None and offset < 0:
|
||||||
offset = max(len(self.history) + offset, 0)
|
offset = max(len(self.history) + offset, 0)
|
||||||
max_items = max_items or MAXIMUM_HISTORY_SIZE
|
max_items = max_items or self.maximum_history_size
|
||||||
if prompt_id in self.history:
|
if prompt_id in self.history:
|
||||||
return {prompt_id: copy.deepcopy(self.history[prompt_id])}
|
return {prompt_id: copy.deepcopy(self.history[prompt_id])}
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -411,21 +411,25 @@ class Qwen25_7BVLI(BaseLlama, torch.nn.Module):
|
|||||||
|
|
||||||
def forward(self, x, attention_mask=None, embeds=None, num_tokens=None, intermediate_output=None, final_layer_norm_intermediate=True, dtype=None, embeds_info=[]):
|
def forward(self, x, attention_mask=None, embeds=None, num_tokens=None, intermediate_output=None, final_layer_norm_intermediate=True, dtype=None, embeds_info=[]):
|
||||||
grid = None
|
grid = None
|
||||||
|
position_ids = None
|
||||||
|
offset = 0
|
||||||
for e in embeds_info:
|
for e in embeds_info:
|
||||||
if e.get("type") == "image":
|
if e.get("type") == "image":
|
||||||
grid = e.get("extra", None)
|
grid = e.get("extra", None)
|
||||||
position_ids = torch.zeros((3, embeds.shape[1]), device=embeds.device)
|
|
||||||
start = e.get("index")
|
start = e.get("index")
|
||||||
position_ids[:, :start] = torch.arange(0, start, device=embeds.device)
|
if position_ids is None:
|
||||||
|
position_ids = torch.zeros((3, embeds.shape[1]), device=embeds.device)
|
||||||
|
position_ids[:, :start] = torch.arange(0, start, device=embeds.device)
|
||||||
end = e.get("size") + start
|
end = e.get("size") + start
|
||||||
len_max = int(grid.max()) // 2
|
len_max = int(grid.max()) // 2
|
||||||
start_next = len_max + start
|
start_next = len_max + start
|
||||||
position_ids[:, end:] = torch.arange(start_next, start_next + (embeds.shape[1] - end), device=embeds.device)
|
position_ids[:, end:] = torch.arange(start_next + offset, start_next + (embeds.shape[1] - end) + offset, device=embeds.device)
|
||||||
position_ids[0, start:end] = start
|
position_ids[0, start:end] = start + offset
|
||||||
max_d = int(grid[0][1]) // 2
|
max_d = int(grid[0][1]) // 2
|
||||||
position_ids[1, start:end] = torch.arange(start, start + max_d, device=embeds.device).unsqueeze(1).repeat(1, math.ceil((end - start) / max_d)).flatten(0)[:end - start]
|
position_ids[1, start:end] = torch.arange(start + offset, start + max_d + offset, device=embeds.device).unsqueeze(1).repeat(1, math.ceil((end - start) / max_d)).flatten(0)[:end - start]
|
||||||
max_d = int(grid[0][2]) // 2
|
max_d = int(grid[0][2]) // 2
|
||||||
position_ids[2, start:end] = torch.arange(start, start + max_d, device=embeds.device).unsqueeze(0).repeat(math.ceil((end - start) / max_d), 1).flatten(0)[:end - start]
|
position_ids[2, start:end] = torch.arange(start + offset, start + max_d + offset, device=embeds.device).unsqueeze(0).repeat(math.ceil((end - start) / max_d), 1).flatten(0)[:end - start]
|
||||||
|
offset += len_max - (end - start)
|
||||||
|
|
||||||
if grid is None:
|
if grid is None:
|
||||||
position_ids = None
|
position_ids = None
|
||||||
|
|||||||
602
comfy_api_nodes/nodes_wan.py
Normal file
602
comfy_api_nodes/nodes_wan.py
Normal file
@ -0,0 +1,602 @@
|
|||||||
|
import re
|
||||||
|
from typing import Optional, Type, Union
|
||||||
|
from typing_extensions import override
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from comfy_api.latest import ComfyExtension, Input, io as comfy_io
|
||||||
|
from comfy_api_nodes.apis.client import (
|
||||||
|
ApiEndpoint,
|
||||||
|
HttpMethod,
|
||||||
|
SynchronousOperation,
|
||||||
|
PollingOperation,
|
||||||
|
EmptyRequest,
|
||||||
|
R,
|
||||||
|
T,
|
||||||
|
)
|
||||||
|
from comfy_api_nodes.util.validation_utils import get_number_of_images, validate_audio_duration
|
||||||
|
|
||||||
|
from comfy_api_nodes.apinode_utils import (
|
||||||
|
download_url_to_image_tensor,
|
||||||
|
download_url_to_video_output,
|
||||||
|
tensor_to_base64_string,
|
||||||
|
audio_to_base64_string,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Text2ImageInputField(BaseModel):
|
||||||
|
prompt: str = Field(...)
|
||||||
|
negative_prompt: Optional[str] = Field(None)
|
||||||
|
|
||||||
|
|
||||||
|
class Text2VideoInputField(BaseModel):
|
||||||
|
prompt: str = Field(...)
|
||||||
|
negative_prompt: Optional[str] = Field(None)
|
||||||
|
audio_url: Optional[str] = Field(None)
|
||||||
|
|
||||||
|
|
||||||
|
class Image2VideoInputField(BaseModel):
|
||||||
|
prompt: str = Field(...)
|
||||||
|
negative_prompt: Optional[str] = Field(None)
|
||||||
|
img_url: str = Field(...)
|
||||||
|
audio_url: Optional[str] = Field(None)
|
||||||
|
|
||||||
|
|
||||||
|
class Txt2ImageParametersField(BaseModel):
|
||||||
|
size: str = Field(...)
|
||||||
|
n: int = Field(1, description="Number of images to generate.") # we support only value=1
|
||||||
|
seed: int = Field(..., ge=0, le=2147483647)
|
||||||
|
prompt_extend: bool = Field(True)
|
||||||
|
watermark: bool = Field(True)
|
||||||
|
|
||||||
|
|
||||||
|
class Text2VideoParametersField(BaseModel):
|
||||||
|
size: str = Field(...)
|
||||||
|
seed: int = Field(..., ge=0, le=2147483647)
|
||||||
|
duration: int = Field(5, ge=5, le=10)
|
||||||
|
prompt_extend: bool = Field(True)
|
||||||
|
watermark: bool = Field(True)
|
||||||
|
audio: bool = Field(False, description="Should be audio generated automatically")
|
||||||
|
|
||||||
|
|
||||||
|
class Image2VideoParametersField(BaseModel):
|
||||||
|
resolution: str = Field(...)
|
||||||
|
seed: int = Field(..., ge=0, le=2147483647)
|
||||||
|
duration: int = Field(5, ge=5, le=10)
|
||||||
|
prompt_extend: bool = Field(True)
|
||||||
|
watermark: bool = Field(True)
|
||||||
|
audio: bool = Field(False, description="Should be audio generated automatically")
|
||||||
|
|
||||||
|
|
||||||
|
class Text2ImageTaskCreationRequest(BaseModel):
|
||||||
|
model: str = Field(...)
|
||||||
|
input: Text2ImageInputField = Field(...)
|
||||||
|
parameters: Txt2ImageParametersField = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class Text2VideoTaskCreationRequest(BaseModel):
|
||||||
|
model: str = Field(...)
|
||||||
|
input: Text2VideoInputField = Field(...)
|
||||||
|
parameters: Text2VideoParametersField = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class Image2VideoTaskCreationRequest(BaseModel):
|
||||||
|
model: str = Field(...)
|
||||||
|
input: Image2VideoInputField = Field(...)
|
||||||
|
parameters: Image2VideoParametersField = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCreationOutputField(BaseModel):
|
||||||
|
task_id: str = Field(...)
|
||||||
|
task_status: str = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCreationResponse(BaseModel):
|
||||||
|
output: Optional[TaskCreationOutputField] = Field(None)
|
||||||
|
request_id: str = Field(...)
|
||||||
|
code: Optional[str] = Field(None, description="The error code of the failed request.")
|
||||||
|
message: Optional[str] = Field(None, description="Details of the failed request.")
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResult(BaseModel):
|
||||||
|
url: Optional[str] = Field(None)
|
||||||
|
code: Optional[str] = Field(None)
|
||||||
|
message: Optional[str] = Field(None)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageTaskStatusOutputField(TaskCreationOutputField):
|
||||||
|
task_id: str = Field(...)
|
||||||
|
task_status: str = Field(...)
|
||||||
|
results: Optional[list[TaskResult]] = Field(None)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoTaskStatusOutputField(TaskCreationOutputField):
|
||||||
|
task_id: str = Field(...)
|
||||||
|
task_status: str = Field(...)
|
||||||
|
video_url: Optional[str] = Field(None)
|
||||||
|
code: Optional[str] = Field(None)
|
||||||
|
message: Optional[str] = Field(None)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageTaskStatusResponse(BaseModel):
|
||||||
|
output: Optional[ImageTaskStatusOutputField] = Field(None)
|
||||||
|
request_id: str = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoTaskStatusResponse(BaseModel):
|
||||||
|
output: Optional[VideoTaskStatusOutputField] = Field(None)
|
||||||
|
request_id: str = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
RES_IN_PARENS = re.compile(r'\((\d+)\s*[x×]\s*(\d+)\)')
|
||||||
|
|
||||||
|
|
||||||
|
async def process_task(
|
||||||
|
auth_kwargs: dict[str, str],
|
||||||
|
url: str,
|
||||||
|
request_model: Type[T],
|
||||||
|
response_model: Type[R],
|
||||||
|
payload: Union[Text2ImageTaskCreationRequest, Text2VideoTaskCreationRequest, Image2VideoTaskCreationRequest],
|
||||||
|
node_id: str,
|
||||||
|
estimated_duration: int,
|
||||||
|
poll_interval: int,
|
||||||
|
) -> Type[R]:
|
||||||
|
initial_response = await SynchronousOperation(
|
||||||
|
endpoint=ApiEndpoint(
|
||||||
|
path=url,
|
||||||
|
method=HttpMethod.POST,
|
||||||
|
request_model=request_model,
|
||||||
|
response_model=TaskCreationResponse,
|
||||||
|
),
|
||||||
|
request=payload,
|
||||||
|
auth_kwargs=auth_kwargs,
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
if not initial_response.output:
|
||||||
|
raise Exception(f"Unknown error occurred: {initial_response.code} - {initial_response.message}")
|
||||||
|
|
||||||
|
return await PollingOperation(
|
||||||
|
poll_endpoint=ApiEndpoint(
|
||||||
|
path=f"/proxy/wan/api/v1/tasks/{initial_response.output.task_id}",
|
||||||
|
method=HttpMethod.GET,
|
||||||
|
request_model=EmptyRequest,
|
||||||
|
response_model=response_model,
|
||||||
|
),
|
||||||
|
completed_statuses=["SUCCEEDED"],
|
||||||
|
failed_statuses=["FAILED", "CANCELED", "UNKNOWN"],
|
||||||
|
status_extractor=lambda x: x.output.task_status,
|
||||||
|
estimated_duration=estimated_duration,
|
||||||
|
poll_interval=poll_interval,
|
||||||
|
node_id=node_id,
|
||||||
|
auth_kwargs=auth_kwargs,
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
|
||||||
|
class WanTextToImageApi(comfy_io.ComfyNode):
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return comfy_io.Schema(
|
||||||
|
node_id="WanTextToImageApi",
|
||||||
|
display_name="Wan Text to Image",
|
||||||
|
category="api node/image/Wan",
|
||||||
|
description="Generates image based on text prompt.",
|
||||||
|
inputs=[
|
||||||
|
comfy_io.Combo.Input(
|
||||||
|
"model",
|
||||||
|
options=["wan2.5-t2i-preview"],
|
||||||
|
default="wan2.5-t2i-preview",
|
||||||
|
tooltip="Model to use.",
|
||||||
|
),
|
||||||
|
comfy_io.String.Input(
|
||||||
|
"prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Prompt used to describe the elements and visual features, supports English/Chinese.",
|
||||||
|
),
|
||||||
|
comfy_io.String.Input(
|
||||||
|
"negative_prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Negative text prompt to guide what to avoid.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Int.Input(
|
||||||
|
"width",
|
||||||
|
default=1024,
|
||||||
|
min=768,
|
||||||
|
max=1440,
|
||||||
|
step=32,
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Int.Input(
|
||||||
|
"height",
|
||||||
|
default=1024,
|
||||||
|
min=768,
|
||||||
|
max=1440,
|
||||||
|
step=32,
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Int.Input(
|
||||||
|
"seed",
|
||||||
|
default=0,
|
||||||
|
min=0,
|
||||||
|
max=2147483647,
|
||||||
|
step=1,
|
||||||
|
display_mode=comfy_io.NumberDisplay.number,
|
||||||
|
control_after_generate=True,
|
||||||
|
tooltip="Seed to use for generation.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Boolean.Input(
|
||||||
|
"prompt_extend",
|
||||||
|
default=True,
|
||||||
|
tooltip="Whether to enhance the prompt with AI assistance.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Boolean.Input(
|
||||||
|
"watermark",
|
||||||
|
default=True,
|
||||||
|
tooltip="Whether to add an \"AI generated\" watermark to the result.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
outputs=[
|
||||||
|
comfy_io.Image.Output(),
|
||||||
|
],
|
||||||
|
hidden=[
|
||||||
|
comfy_io.Hidden.auth_token_comfy_org,
|
||||||
|
comfy_io.Hidden.api_key_comfy_org,
|
||||||
|
comfy_io.Hidden.unique_id,
|
||||||
|
],
|
||||||
|
is_api_node=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def execute(
|
||||||
|
cls,
|
||||||
|
model: str,
|
||||||
|
prompt: str,
|
||||||
|
negative_prompt: str = "",
|
||||||
|
width: int = 1024,
|
||||||
|
height: int = 1024,
|
||||||
|
seed: int = 0,
|
||||||
|
prompt_extend: bool = True,
|
||||||
|
watermark: bool = True,
|
||||||
|
):
|
||||||
|
payload = Text2ImageTaskCreationRequest(
|
||||||
|
model=model,
|
||||||
|
input=Text2ImageInputField(prompt=prompt, negative_prompt=negative_prompt),
|
||||||
|
parameters=Txt2ImageParametersField(
|
||||||
|
size=f"{width}*{height}",
|
||||||
|
seed=seed,
|
||||||
|
prompt_extend=prompt_extend,
|
||||||
|
watermark=watermark,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = await process_task(
|
||||||
|
{
|
||||||
|
"auth_token": cls.hidden.auth_token_comfy_org,
|
||||||
|
"comfy_api_key": cls.hidden.api_key_comfy_org,
|
||||||
|
},
|
||||||
|
"/proxy/wan/api/v1/services/aigc/text2image/image-synthesis",
|
||||||
|
request_model=Text2ImageTaskCreationRequest,
|
||||||
|
response_model=ImageTaskStatusResponse,
|
||||||
|
payload=payload,
|
||||||
|
node_id=cls.hidden.unique_id,
|
||||||
|
estimated_duration=9,
|
||||||
|
poll_interval=3,
|
||||||
|
)
|
||||||
|
return comfy_io.NodeOutput(await download_url_to_image_tensor(str(response.output.results[0].url)))
|
||||||
|
|
||||||
|
|
||||||
|
class WanTextToVideoApi(comfy_io.ComfyNode):
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return comfy_io.Schema(
|
||||||
|
node_id="WanTextToVideoApi",
|
||||||
|
display_name="Wan Text to Video",
|
||||||
|
category="api node/video/Wan",
|
||||||
|
description="Generates video based on text prompt.",
|
||||||
|
inputs=[
|
||||||
|
comfy_io.Combo.Input(
|
||||||
|
"model",
|
||||||
|
options=["wan2.5-t2v-preview"],
|
||||||
|
default="wan2.5-t2v-preview",
|
||||||
|
tooltip="Model to use.",
|
||||||
|
),
|
||||||
|
comfy_io.String.Input(
|
||||||
|
"prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Prompt used to describe the elements and visual features, supports English/Chinese.",
|
||||||
|
),
|
||||||
|
comfy_io.String.Input(
|
||||||
|
"negative_prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Negative text prompt to guide what to avoid.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Combo.Input(
|
||||||
|
"size",
|
||||||
|
options=[
|
||||||
|
"480p: 1:1 (624x624)",
|
||||||
|
"480p: 16:9 (832x480)",
|
||||||
|
"480p: 9:16 (480x832)",
|
||||||
|
"720p: 1:1 (960x960)",
|
||||||
|
"720p: 16:9 (1280x720)",
|
||||||
|
"720p: 9:16 (720x1280)",
|
||||||
|
"720p: 4:3 (1088x832)",
|
||||||
|
"720p: 3:4 (832x1088)",
|
||||||
|
"1080p: 1:1 (1440x1440)",
|
||||||
|
"1080p: 16:9 (1920x1080)",
|
||||||
|
"1080p: 9:16 (1080x1920)",
|
||||||
|
"1080p: 4:3 (1632x1248)",
|
||||||
|
"1080p: 3:4 (1248x1632)",
|
||||||
|
],
|
||||||
|
default="480p: 1:1 (624x624)",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Int.Input(
|
||||||
|
"duration",
|
||||||
|
default=5,
|
||||||
|
min=5,
|
||||||
|
max=10,
|
||||||
|
step=5,
|
||||||
|
display_mode=comfy_io.NumberDisplay.number,
|
||||||
|
tooltip="Available durations: 5 and 10 seconds",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Audio.Input(
|
||||||
|
"audio",
|
||||||
|
optional=True,
|
||||||
|
tooltip="Audio must contain a clear, loud voice, without extraneous noise, background music.",
|
||||||
|
),
|
||||||
|
comfy_io.Int.Input(
|
||||||
|
"seed",
|
||||||
|
default=0,
|
||||||
|
min=0,
|
||||||
|
max=2147483647,
|
||||||
|
step=1,
|
||||||
|
display_mode=comfy_io.NumberDisplay.number,
|
||||||
|
control_after_generate=True,
|
||||||
|
tooltip="Seed to use for generation.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Boolean.Input(
|
||||||
|
"generate_audio",
|
||||||
|
default=False,
|
||||||
|
optional=True,
|
||||||
|
tooltip="If there is no audio input, generate audio automatically.",
|
||||||
|
),
|
||||||
|
comfy_io.Boolean.Input(
|
||||||
|
"prompt_extend",
|
||||||
|
default=True,
|
||||||
|
tooltip="Whether to enhance the prompt with AI assistance.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Boolean.Input(
|
||||||
|
"watermark",
|
||||||
|
default=True,
|
||||||
|
tooltip="Whether to add an \"AI generated\" watermark to the result.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
outputs=[
|
||||||
|
comfy_io.Video.Output(),
|
||||||
|
],
|
||||||
|
hidden=[
|
||||||
|
comfy_io.Hidden.auth_token_comfy_org,
|
||||||
|
comfy_io.Hidden.api_key_comfy_org,
|
||||||
|
comfy_io.Hidden.unique_id,
|
||||||
|
],
|
||||||
|
is_api_node=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def execute(
|
||||||
|
cls,
|
||||||
|
model: str,
|
||||||
|
prompt: str,
|
||||||
|
negative_prompt: str = "",
|
||||||
|
size: str = "480p: 1:1 (624x624)",
|
||||||
|
duration: int = 5,
|
||||||
|
audio: Optional[Input.Audio] = None,
|
||||||
|
seed: int = 0,
|
||||||
|
generate_audio: bool = False,
|
||||||
|
prompt_extend: bool = True,
|
||||||
|
watermark: bool = True,
|
||||||
|
):
|
||||||
|
width, height = RES_IN_PARENS.search(size).groups()
|
||||||
|
audio_url = None
|
||||||
|
if audio is not None:
|
||||||
|
validate_audio_duration(audio, 3.0, 29.0)
|
||||||
|
audio_url = "data:audio/mp3;base64," + audio_to_base64_string(audio, "mp3", "libmp3lame")
|
||||||
|
payload = Text2VideoTaskCreationRequest(
|
||||||
|
model=model,
|
||||||
|
input=Text2VideoInputField(prompt=prompt, negative_prompt=negative_prompt, audio_url=audio_url),
|
||||||
|
parameters=Text2VideoParametersField(
|
||||||
|
size=f"{width}*{height}",
|
||||||
|
duration=duration,
|
||||||
|
seed=seed,
|
||||||
|
audio=generate_audio,
|
||||||
|
prompt_extend=prompt_extend,
|
||||||
|
watermark=watermark,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = await process_task(
|
||||||
|
{
|
||||||
|
"auth_token": cls.hidden.auth_token_comfy_org,
|
||||||
|
"comfy_api_key": cls.hidden.api_key_comfy_org,
|
||||||
|
},
|
||||||
|
"/proxy/wan/api/v1/services/aigc/video-generation/video-synthesis",
|
||||||
|
request_model=Text2VideoTaskCreationRequest,
|
||||||
|
response_model=VideoTaskStatusResponse,
|
||||||
|
payload=payload,
|
||||||
|
node_id=cls.hidden.unique_id,
|
||||||
|
estimated_duration=120 * int(duration / 5),
|
||||||
|
poll_interval=6,
|
||||||
|
)
|
||||||
|
return comfy_io.NodeOutput(await download_url_to_video_output(response.output.video_url))
|
||||||
|
|
||||||
|
|
||||||
|
class WanImageToVideoApi(comfy_io.ComfyNode):
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return comfy_io.Schema(
|
||||||
|
node_id="WanImageToVideoApi",
|
||||||
|
display_name="Wan Image to Video",
|
||||||
|
category="api node/video/Wan",
|
||||||
|
description="Generates video based on the first frame and text prompt.",
|
||||||
|
inputs=[
|
||||||
|
comfy_io.Combo.Input(
|
||||||
|
"model",
|
||||||
|
options=["wan2.5-i2v-preview"],
|
||||||
|
default="wan2.5-i2v-preview",
|
||||||
|
tooltip="Model to use.",
|
||||||
|
),
|
||||||
|
comfy_io.Image.Input(
|
||||||
|
"image",
|
||||||
|
),
|
||||||
|
comfy_io.String.Input(
|
||||||
|
"prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Prompt used to describe the elements and visual features, supports English/Chinese.",
|
||||||
|
),
|
||||||
|
comfy_io.String.Input(
|
||||||
|
"negative_prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Negative text prompt to guide what to avoid.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Combo.Input(
|
||||||
|
"resolution",
|
||||||
|
options=[
|
||||||
|
"480P",
|
||||||
|
"720P",
|
||||||
|
"1080P",
|
||||||
|
],
|
||||||
|
default="480P",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Int.Input(
|
||||||
|
"duration",
|
||||||
|
default=5,
|
||||||
|
min=5,
|
||||||
|
max=10,
|
||||||
|
step=5,
|
||||||
|
display_mode=comfy_io.NumberDisplay.number,
|
||||||
|
tooltip="Available durations: 5 and 10 seconds",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Audio.Input(
|
||||||
|
"audio",
|
||||||
|
optional=True,
|
||||||
|
tooltip="Audio must contain a clear, loud voice, without extraneous noise, background music.",
|
||||||
|
),
|
||||||
|
comfy_io.Int.Input(
|
||||||
|
"seed",
|
||||||
|
default=0,
|
||||||
|
min=0,
|
||||||
|
max=2147483647,
|
||||||
|
step=1,
|
||||||
|
display_mode=comfy_io.NumberDisplay.number,
|
||||||
|
control_after_generate=True,
|
||||||
|
tooltip="Seed to use for generation.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Boolean.Input(
|
||||||
|
"generate_audio",
|
||||||
|
default=False,
|
||||||
|
optional=True,
|
||||||
|
tooltip="If there is no audio input, generate audio automatically.",
|
||||||
|
),
|
||||||
|
comfy_io.Boolean.Input(
|
||||||
|
"prompt_extend",
|
||||||
|
default=True,
|
||||||
|
tooltip="Whether to enhance the prompt with AI assistance.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
comfy_io.Boolean.Input(
|
||||||
|
"watermark",
|
||||||
|
default=True,
|
||||||
|
tooltip="Whether to add an \"AI generated\" watermark to the result.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
outputs=[
|
||||||
|
comfy_io.Video.Output(),
|
||||||
|
],
|
||||||
|
hidden=[
|
||||||
|
comfy_io.Hidden.auth_token_comfy_org,
|
||||||
|
comfy_io.Hidden.api_key_comfy_org,
|
||||||
|
comfy_io.Hidden.unique_id,
|
||||||
|
],
|
||||||
|
is_api_node=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def execute(
|
||||||
|
cls,
|
||||||
|
model: str,
|
||||||
|
image: torch.Tensor,
|
||||||
|
prompt: str,
|
||||||
|
negative_prompt: str = "",
|
||||||
|
resolution: str = "480P",
|
||||||
|
duration: int = 5,
|
||||||
|
audio: Optional[Input.Audio] = None,
|
||||||
|
seed: int = 0,
|
||||||
|
generate_audio: bool = False,
|
||||||
|
prompt_extend: bool = True,
|
||||||
|
watermark: bool = True,
|
||||||
|
):
|
||||||
|
if get_number_of_images(image) != 1:
|
||||||
|
raise ValueError("Exactly one input image is required.")
|
||||||
|
image_url = "data:image/png;base64," + tensor_to_base64_string(image, total_pixels=2000*2000)
|
||||||
|
audio_url = None
|
||||||
|
if audio is not None:
|
||||||
|
validate_audio_duration(audio, 3.0, 29.0)
|
||||||
|
audio_url = "data:audio/mp3;base64," + audio_to_base64_string(audio, "mp3", "libmp3lame")
|
||||||
|
payload = Image2VideoTaskCreationRequest(
|
||||||
|
model=model,
|
||||||
|
input=Image2VideoInputField(
|
||||||
|
prompt=prompt, negative_prompt=negative_prompt, img_url=image_url, audio_url=audio_url
|
||||||
|
),
|
||||||
|
parameters=Image2VideoParametersField(
|
||||||
|
resolution=resolution,
|
||||||
|
duration=duration,
|
||||||
|
seed=seed,
|
||||||
|
audio=generate_audio,
|
||||||
|
prompt_extend=prompt_extend,
|
||||||
|
watermark=watermark,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response = await process_task(
|
||||||
|
{
|
||||||
|
"auth_token": cls.hidden.auth_token_comfy_org,
|
||||||
|
"comfy_api_key": cls.hidden.api_key_comfy_org,
|
||||||
|
},
|
||||||
|
"/proxy/wan/api/v1/services/aigc/video-generation/video-synthesis",
|
||||||
|
request_model=Image2VideoTaskCreationRequest,
|
||||||
|
response_model=VideoTaskStatusResponse,
|
||||||
|
payload=payload,
|
||||||
|
node_id=cls.hidden.unique_id,
|
||||||
|
estimated_duration=120 * int(duration / 5),
|
||||||
|
poll_interval=6,
|
||||||
|
)
|
||||||
|
return comfy_io.NodeOutput(await download_url_to_video_output(response.output.video_url))
|
||||||
|
|
||||||
|
|
||||||
|
class WanApiExtension(ComfyExtension):
|
||||||
|
@override
|
||||||
|
async def get_node_list(self) -> list[type[comfy_io.ComfyNode]]:
|
||||||
|
return [
|
||||||
|
WanTextToImageApi,
|
||||||
|
WanTextToVideoApi,
|
||||||
|
WanImageToVideoApi,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def comfy_entrypoint() -> WanApiExtension:
|
||||||
|
return WanApiExtension()
|
||||||
@ -43,6 +43,61 @@ class TextEncodeQwenImageEdit:
|
|||||||
return (conditioning,)
|
return (conditioning,)
|
||||||
|
|
||||||
|
|
||||||
|
class TextEncodeQwenImageEditPlus:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(s):
|
||||||
|
return {"required": {
|
||||||
|
"clip": ("CLIP", ),
|
||||||
|
"prompt": ("STRING", {"multiline": True, "dynamicPrompts": True}),
|
||||||
|
},
|
||||||
|
"optional": {"vae": ("VAE", ),
|
||||||
|
"image1": ("IMAGE", ),
|
||||||
|
"image2": ("IMAGE", ),
|
||||||
|
"image3": ("IMAGE", ),
|
||||||
|
}}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("CONDITIONING",)
|
||||||
|
FUNCTION = "encode"
|
||||||
|
|
||||||
|
CATEGORY = "advanced/conditioning"
|
||||||
|
|
||||||
|
def encode(self, clip, prompt, vae=None, image1=None, image2=None, image3=None):
|
||||||
|
ref_latents = []
|
||||||
|
images = [image1, image2, image3]
|
||||||
|
images_vl = []
|
||||||
|
llama_template = "<|im_start|>system\nDescribe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n"
|
||||||
|
image_prompt = ""
|
||||||
|
|
||||||
|
for i, image in enumerate(images):
|
||||||
|
if image is not None:
|
||||||
|
samples = image.movedim(-1, 1)
|
||||||
|
total = int(384 * 384)
|
||||||
|
|
||||||
|
scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2]))
|
||||||
|
width = round(samples.shape[3] * scale_by)
|
||||||
|
height = round(samples.shape[2] * scale_by)
|
||||||
|
|
||||||
|
s = comfy.utils.common_upscale(samples, width, height, "area", "disabled")
|
||||||
|
images_vl.append(s.movedim(1, -1))
|
||||||
|
if vae is not None:
|
||||||
|
total = int(1024 * 1024)
|
||||||
|
scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2]))
|
||||||
|
width = round(samples.shape[3] * scale_by / 8.0) * 8
|
||||||
|
height = round(samples.shape[2] * scale_by / 8.0) * 8
|
||||||
|
|
||||||
|
s = comfy.utils.common_upscale(samples, width, height, "area", "disabled")
|
||||||
|
ref_latents.append(vae.encode(s.movedim(1, -1)[:, :, :, :3]))
|
||||||
|
|
||||||
|
image_prompt += "Picture {}: <|vision_start|><|image_pad|><|vision_end|>".format(i + 1)
|
||||||
|
|
||||||
|
tokens = clip.tokenize(image_prompt + prompt, images=images_vl, llama_template=llama_template)
|
||||||
|
conditioning = clip.encode_from_tokens_scheduled(tokens)
|
||||||
|
if len(ref_latents) > 0:
|
||||||
|
conditioning = node_helpers.conditioning_set_values(conditioning, {"reference_latents": ref_latents}, append=True)
|
||||||
|
return (conditioning, )
|
||||||
|
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
"TextEncodeQwenImageEdit": TextEncodeQwenImageEdit,
|
"TextEncodeQwenImageEdit": TextEncodeQwenImageEdit,
|
||||||
|
"TextEncodeQwenImageEditPlus": TextEncodeQwenImageEditPlus,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1224,7 +1224,7 @@ class WanAnimateToVideo(io.ComfyNode):
|
|||||||
background_video = background_video[video_frame_offset:]
|
background_video = background_video[video_frame_offset:]
|
||||||
background_video = comfy.utils.common_upscale(background_video[:length].movedim(-1, 1), width, height, "area", "center").movedim(1, -1)
|
background_video = comfy.utils.common_upscale(background_video[:length].movedim(-1, 1), width, height, "area", "center").movedim(1, -1)
|
||||||
if background_video.shape[0] > ref_images_num:
|
if background_video.shape[0] > ref_images_num:
|
||||||
image[ref_images_num:background_video.shape[0] - ref_images_num] = background_video[ref_images_num:]
|
image[ref_images_num:background_video.shape[0]] = background_video[ref_images_num:]
|
||||||
|
|
||||||
mask_refmotion = torch.ones((1, 1, latent_length * 4, concat_latent_image.shape[-2], concat_latent_image.shape[-1]), device=mask.device, dtype=mask.dtype)
|
mask_refmotion = torch.ones((1, 1, latent_length * 4, concat_latent_image.shape[-2], concat_latent_image.shape[-1]), device=mask.device, dtype=mask.dtype)
|
||||||
if continue_motion is not None:
|
if continue_motion is not None:
|
||||||
@ -1243,7 +1243,7 @@ class WanAnimateToVideo(io.ComfyNode):
|
|||||||
character_mask = character_mask.unsqueeze(1)
|
character_mask = character_mask.unsqueeze(1)
|
||||||
character_mask = comfy.utils.common_upscale(character_mask[:, :, :length], concat_latent_image.shape[-1], concat_latent_image.shape[-2], "nearest-exact", "center")
|
character_mask = comfy.utils.common_upscale(character_mask[:, :, :length], concat_latent_image.shape[-1], concat_latent_image.shape[-2], "nearest-exact", "center")
|
||||||
if character_mask.shape[2] > ref_images_num:
|
if character_mask.shape[2] > ref_images_num:
|
||||||
mask_refmotion[:, :, ref_images_num:character_mask.shape[2] + ref_images_num] = character_mask[:, :, ref_images_num:]
|
mask_refmotion[:, :, ref_images_num:character_mask.shape[2]] = character_mask[:, :, ref_images_num:]
|
||||||
|
|
||||||
concat_latent_image = torch.cat((concat_latent_image, vae.encode(image[:, :, :, :3])), dim=2)
|
concat_latent_image = torch.cat((concat_latent_image, vae.encode(image[:, :, :, :3])), dim=2)
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui"
|
name = "comfyui"
|
||||||
version = "0.3.59"
|
version = "0.3.60"
|
||||||
description = "An installable version of ComfyUI"
|
description = "An installable version of ComfyUI"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [
|
authors = [
|
||||||
@ -19,7 +19,7 @@ classifiers = [
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"comfyui-frontend-package>=1.26.13",
|
"comfyui-frontend-package>=1.26.13",
|
||||||
"comfyui-workflow-templates>=0.1.81",
|
"comfyui-workflow-templates>=0.1.86",
|
||||||
"comfyui-embedded-docs>=0.2.6",
|
"comfyui-embedded-docs>=0.2.6",
|
||||||
"torch",
|
"torch",
|
||||||
"torchvision",
|
"torchvision",
|
||||||
|
|||||||
@ -114,6 +114,9 @@ class ComfyClient:
|
|||||||
image_objects.append(Image.open(image["abs_path"]))
|
image_objects.append(Image.open(image["abs_path"]))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def get_all_history(self, *args, **kwargs):
|
||||||
|
return self.embedded_client.history.copy(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
# Loop through these variables
|
# Loop through these variables
|
||||||
@pytest.mark.execution
|
@pytest.mark.execution
|
||||||
@ -688,3 +691,93 @@ class TestExecution:
|
|||||||
assert False, "Should have raised an error for empty partial execution list"
|
assert False, "Should have raised an error for empty partial execution list"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Expected behavior
|
pass # Expected behavior
|
||||||
|
|
||||||
|
async def _create_history_item(self, client, builder):
|
||||||
|
g = GraphBuilder(prefix="offset_test")
|
||||||
|
input_node = g.node(
|
||||||
|
"StubImage", content="BLACK", height=32, width=32, batch_size=1
|
||||||
|
)
|
||||||
|
g.node("SaveImage", images=input_node.out(0))
|
||||||
|
return await client.run(g)
|
||||||
|
|
||||||
|
async def test_offset_returns_different_items_than_beginning_of_history(
|
||||||
|
self, client: ComfyClient, builder: GraphBuilder
|
||||||
|
):
|
||||||
|
"""Test that offset skips items at the beginning"""
|
||||||
|
for _ in range(5):
|
||||||
|
await self._create_history_item(client, builder)
|
||||||
|
|
||||||
|
first_two = client.get_all_history(max_items=2, offset=0)
|
||||||
|
next_two = client.get_all_history(max_items=2, offset=2)
|
||||||
|
|
||||||
|
assert set(first_two.keys()).isdisjoint(
|
||||||
|
set(next_two.keys())
|
||||||
|
), "Offset should skip initial items"
|
||||||
|
|
||||||
|
async def test_offset_beyond_history_length_returns_empty(
|
||||||
|
self, client: ComfyClient, builder: GraphBuilder
|
||||||
|
):
|
||||||
|
"""Test offset larger than total history returns empty result"""
|
||||||
|
await self._create_history_item(client, builder)
|
||||||
|
|
||||||
|
result = client.get_all_history(offset=100)
|
||||||
|
assert len(result) == 0, "Large offset should return no items"
|
||||||
|
|
||||||
|
async def test_offset_at_exact_history_length_returns_empty(
|
||||||
|
self, client: ComfyClient, builder: GraphBuilder
|
||||||
|
):
|
||||||
|
"""Test offset equal to history length returns empty"""
|
||||||
|
for _ in range(3):
|
||||||
|
await self._create_history_item(client, builder)
|
||||||
|
|
||||||
|
all_history = client.get_all_history()
|
||||||
|
result = client.get_all_history(offset=len(all_history))
|
||||||
|
assert len(result) == 0, "Offset at history length should return empty"
|
||||||
|
|
||||||
|
async def test_offset_zero_equals_no_offset_parameter(
|
||||||
|
self, client: ComfyClient, builder: GraphBuilder
|
||||||
|
):
|
||||||
|
"""Test offset=0 behaves same as omitting offset"""
|
||||||
|
await self._create_history_item(client, builder)
|
||||||
|
|
||||||
|
with_zero = client.get_all_history(offset=0)
|
||||||
|
without_offset = client.get_all_history()
|
||||||
|
|
||||||
|
assert with_zero == without_offset, "offset=0 should equal no offset"
|
||||||
|
|
||||||
|
async def test_offset_without_max_items_skips_from_beginning(
|
||||||
|
self, client: ComfyClient, builder: GraphBuilder
|
||||||
|
):
|
||||||
|
"""Test offset alone (no max_items) returns remaining items"""
|
||||||
|
for _ in range(4):
|
||||||
|
await self._create_history_item(client, builder)
|
||||||
|
|
||||||
|
all_items = client.get_all_history()
|
||||||
|
offset_items = client.get_all_history(offset=2)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
len(offset_items) == len(all_items) - 2
|
||||||
|
), "Offset should skip specified number of items"
|
||||||
|
|
||||||
|
async def test_offset_with_max_items_returns_correct_window(
|
||||||
|
self, client: ComfyClient, builder: GraphBuilder
|
||||||
|
):
|
||||||
|
"""Test offset + max_items returns correct slice of history"""
|
||||||
|
for _ in range(6):
|
||||||
|
await self._create_history_item(client, builder)
|
||||||
|
|
||||||
|
window = client.get_all_history(max_items=2, offset=1)
|
||||||
|
assert len(window) <= 2, "Should respect max_items limit"
|
||||||
|
|
||||||
|
async def test_offset_near_end_returns_remaining_items_only(
|
||||||
|
self, client: ComfyClient, builder: GraphBuilder
|
||||||
|
):
|
||||||
|
"""Test offset near end of history returns only remaining items"""
|
||||||
|
for _ in range(3):
|
||||||
|
await self._create_history_item(client, builder)
|
||||||
|
|
||||||
|
all_history = client.get_all_history()
|
||||||
|
# Offset to near the end
|
||||||
|
result = client.get_all_history(max_items=5, offset=len(all_history) - 1)
|
||||||
|
|
||||||
|
assert len(result) <= 1, "Should return at most 1 item when offset is near end"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user