feat(video): add configurable Gemini input

This commit is contained in:
silveroxides
2026-08-30 03:11:18 +02:00
parent 92529f388e
commit d2f1f4950f
5 changed files with 253 additions and 25 deletions
+5 -3
View File
@@ -56,8 +56,9 @@ Use the `Expanded Gemini Text/Image` node for text generation:
- Connect the API key configuration node to the `config` input.
- Enter your prompt text in `prompt`.
- Adjust generation parameters (temperature, top_p, top_k, etc.).
- If needed, connect an image to the `input_image` input for image understanding.
- Enable `use_cache` on the configuration node to reuse unchanged prompt, system instruction, and image context through Gemini after the local result cache misses.
- If needed, connect images through `image_inputs`. For video, pass a native ComfyUI video through `Configure Gemini Video Input`, then connect its output to `video`.
- Configured video is normalized to MP4/H.264 and sent with embedded audio at the selected sampling FPS. The normalized video must be smaller than 100 MB.
- Enable `use_cache` on the configuration node to reuse unchanged prompt, system instruction, image, and video context through Gemini after the local result cache misses.
### 3. Proxy Settings
@@ -91,7 +92,8 @@ If you are in China or other regions requiring a proxy:
#### Optional Parameters
- `input_image`: Input image (for image understanding)
- `video`: Optional `GEMINI_VIDEO_CONFIG` input. Its video is sent before images and prompt with embedded audio.
- `image_inputs`: Optional ordered image autogrow inputs for image understanding.
- `use_proxy`: Whether to use a proxy (True/False)
- `proxy_host`: Proxy host address
- `proxy_port`: Proxy port
+4 -3
View File
@@ -1,5 +1,5 @@
from comfy_api.latest import ComfyExtension, io
from .gemini_nodes import GetKeyAPI, SSL_GeminiAPIKeyConfig, SSL_GeminiTextPrompt
from .gemini_nodes import GetKeyAPI, SSL_GeminiAPIKeyConfig, SSL_GeminiVideoConfig, SSL_GeminiTextPrompt
class GeminiExtension(ComfyExtension):
@@ -9,8 +9,9 @@ class GeminiExtension(ComfyExtension):
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [
GetKeyAPI,
SSL_GeminiAPIKeyConfig,
SSL_GeminiTextPrompt,
SSL_GeminiAPIKeyConfig,
SSL_GeminiVideoConfig,
SSL_GeminiTextPrompt,
]
+102 -9
View File
@@ -8,7 +8,7 @@ import cv2
from PIL import Image
from io import BytesIO
import folder_paths # type: ignore[reportMissingImports]
from comfy_api.latest import ComfyExtension, UI, IO # type: ignore[reportMissingImports]
from comfy_api.latest import ComfyExtension, UI, IO, Types # type: ignore[reportMissingImports]
from google import genai
from google.genai import errors, types
import time
@@ -145,14 +145,39 @@ class SSL_GeminiAPIKeyConfig(IO.ComfyNode):
class SSL_GeminiVideoConfig(IO.ComfyNode):
GeminiVideoConfig = IO.Custom("GEMINI_VIDEO_CONFIG")
@classmethod
def define_schema(cls) -> IO.Schema:
return IO.Schema(
node_id="SSL_GeminiVideoConfig",
display_name="Configure Gemini Video Input",
category="API/Gemini",
inputs=[
IO.Video.Input("video"),
IO.Int.Input("fps", default=1, min=1, max=24, step=1, tooltip="Frames per second sampled by Gemini for video understanding."),
],
outputs=[cls.GeminiVideoConfig.Output("video_config")],
)
@classmethod
def execute(cls, video: IO.Video.Type, fps: int) -> IO.NodeOutput:
return IO.NodeOutput({"video": video, "fps": int(fps)})
class SSL_GeminiTextPrompt(IO.ComfyNode):
GemConfig = IO.Custom("GEMINI_CONFIG")
GeminiVideoConfig = IO.Custom("GEMINI_VIDEO_CONFIG")
_cache: dict = {}
_seed_map_cache: dict = {} # Maps (input_seed, fingerprint_without_seed) -> successful_gemini_seed
_client_cache: dict = {} # Maps client_key tuple -> genai.Client instance
_context_cache: dict = {}
_context_cache_lock = threading.Lock()
GEMINI_3_7_FLASH = "gemini-3.7-flash"
VIDEO_MIME_TYPE = "video/mp4"
VIDEO_INLINE_LIMIT_BYTES = 100 * 1024 * 1024
VERTEX_CACHE_INLINE_LIMIT_BYTES = 10 * 1024 * 1024
# Placeholder only. Keep this out of the model combo until Google's hosted-model
# documentation confirms the final ID and gemini-3.6-flash-equivalent capabilities
@@ -216,6 +241,7 @@ class SSL_GeminiTextPrompt(IO.ComfyNode):
IO.String.Input("retry_pattern", default="", optional=True, multiline=False, tooltip="Regex pattern to match in response text. If matched, retry with new seed. Leave empty to disable."),
IO.Int.Input("max_retries", default=3, min=0, max=10, step=1, tooltip="Maximum number of retry attempts when pattern matches. 0 disables retry."),
IO.String.Input("timeout_fallback_text", default="", optional=True, multiline=True, tooltip="Text returned when the Gemini request times out. Leave empty to return the standard timeout message."),
cls.GeminiVideoConfig.Input("video", optional=True, tooltip="Optional configured Gemini video input with embedded audio and sampling FPS."),
IO.Autogrow.Input(
"image_inputs",
template=image_template,
@@ -320,6 +346,22 @@ class SSL_GeminiTextPrompt(IO.ComfyNode):
)
return frames, batch_counts
@classmethod
def _serialize_video(cls, video: IO.Video.Type | None):
if video is None:
return None, None
buffer = BytesIO()
video.save_to(
buffer,
format=Types.VideoContainer.MP4,
codec=Types.VideoCodec.H264,
)
video_bytes = buffer.getvalue()
if len(video_bytes) >= cls.VIDEO_INLINE_LIMIT_BYTES:
raise ValueError("Gemini video input must be smaller than 100 MB after MP4/H.264 normalization.")
return video_bytes, cls.VIDEO_MIME_TYPE
@classmethod
def _resolve_gemini_3_7_thinking_level(cls, thinking_level):
if thinking_level is None or thinking_level == "None":
@@ -339,6 +381,7 @@ class SSL_GeminiTextPrompt(IO.ComfyNode):
@classmethod
def _compute_fingerprint_and_check_cache(cls, config, prompt, system_instruction, model, temperature, top_p, top_k, max_output_tokens,
include_images, aspect_ratio, bypass_mode, thinking_budget, use_seed, seed,
video_hash=None, video_mime_type=None, video_fps=None,
image_inputs: IO.Autogrow.Type | None = None,
use_proxy=False, proxy_host="127.0.0.1", proxy_port=7890, timeout=30,
include_thoughts=False, thinking_level=None, media_resolution=None,
@@ -418,6 +461,9 @@ class SSL_GeminiTextPrompt(IO.ComfyNode):
bool(use_cache),
int(cache_ttl_minutes) if use_cache else 0,
int(cache_seed) if use_cache and use_seed else 0,
video_hash,
video_mime_type,
video_fps,
image_hashes,
bool(use_proxy),
str(proxy_host),
@@ -505,12 +551,15 @@ class SSL_GeminiTextPrompt(IO.ComfyNode):
@staticmethod
def _build_context_cache_key(client_key, model, system_instruction, prompt,
image_hashes, media_resolution):
video_hash, video_mime_type, video_fps, image_hashes, media_resolution):
return (
client_key,
model,
system_instruction,
prompt,
video_hash,
video_mime_type,
video_fps,
tuple(image_hashes),
str(media_resolution),
)
@@ -665,15 +714,26 @@ class SSL_GeminiTextPrompt(IO.ComfyNode):
use_proxy=False, proxy_host="127.0.0.1", proxy_port=7890, use_seed=False, seed=0, timeout=30,
include_thoughts=False, thinking_level=None, media_resolution=None,
retry_pattern="", max_retries=3, timeout_fallback_text="",
video: GeminiVideoConfig.Type | None = None,
image_inputs: IO.Autogrow.Type | None = None) -> IO.NodeOutput:
print(f"[INFO] SSL_GeminiTextPrompt execute called, model: {model}")
use_cache = bool(config.get("use_cache", False))
cache_ttl_minutes = int(config.get("cache_ttl_minutes", 60))
cache_seed = int(config.get("cache_seed", 0))
video_input = video.get("video") if video is not None else None
video_fps = int(video.get("fps", 1)) if video is not None else None
try:
video_bytes, video_mime_type = cls._serialize_video(video_input)
except Exception as e:
print(f"[ERROR] Error processing input video: {e}")
output_seed = cache_seed if use_cache and use_seed else seed if use_seed else 0
return IO.NodeOutput(f"Error processing input video: {e}", cls.generate_empty_image(), output_seed)
video_hash = hashlib.sha256(video_bytes).hexdigest() if video_bytes is not None else None
fingerprint, cached = cls._compute_fingerprint_and_check_cache(
config, prompt, system_instruction, model, temperature, top_p, top_k, max_output_tokens,
include_images, aspect_ratio, bypass_mode, thinking_budget, use_seed, seed,
video_hash, video_mime_type, video_fps,
image_inputs,
use_proxy, proxy_host, proxy_port, timeout,
include_thoughts, thinking_level, media_resolution,
@@ -837,9 +897,27 @@ class SSL_GeminiTextPrompt(IO.ComfyNode):
print(f"[ERROR] Gemini client initialization failed: {str(e)}")
return IO.NodeOutput(f"Gemini client initialization failed: {str(e)}", cls.generate_empty_image(), actual_seed if actual_seed is not None else 0)
# Prepare contents (images + prompt)
# Prepare contents (video + images + prompt)
image_frames, image_batch_counts = cls._ordered_image_frames(image_inputs)
context_image_hashes = []
media_parts = []
context_inline_bytes = (
(len(video_bytes) if video_bytes is not None else 0)
+ len(padded_prompt.encode("utf-8"))
+ len(padded_system_instruction.encode("utf-8"))
)
if video_bytes is not None:
if model in cls.MEDIA_RES_MODELS and media_resolution is not None and media_resolution != "unspecified":
video_part = types.Part.from_bytes(
data=video_bytes,
mime_type=video_mime_type,
media_resolution=f"MEDIA_RESOLUTION_{media_resolution.upper()}",
)
else:
video_part = types.Part.from_bytes(data=video_bytes, mime_type=video_mime_type)
video_part.video_metadata = types.VideoMetadata(fps=video_fps)
media_parts.append(video_part)
if image_frames:
try:
@@ -851,6 +929,7 @@ class SSL_GeminiTextPrompt(IO.ComfyNode):
img_byte_arr = BytesIO()
pil_img.save(img_byte_arr, format='PNG')
img_bytes = img_byte_arr.getvalue()
context_inline_bytes += len(img_bytes)
context_image_hashes.append(hashlib.sha256(img_bytes).hexdigest())
if model in cls.MEDIA_RES_MODELS and media_resolution is not None and media_resolution != "unspecified":
img_part = types.Part.from_bytes(
@@ -872,14 +951,16 @@ class SSL_GeminiTextPrompt(IO.ComfyNode):
f"[INFO] Prepared Gemini image inputs: {batch_summary}, "
f"total={len(img_parts)}"
)
contents = [
types.UserContent(
parts=img_parts + [types.Part.from_text(text=padded_prompt)]
)
]
media_parts.extend(img_parts)
except Exception as e:
print(f"[ERROR] Error processing input image: {str(e)}")
return IO.NodeOutput(f"Error processing input image: {str(e)}", cls.generate_empty_image(), actual_seed if actual_seed is not None else 0)
if media_parts:
contents = [
types.UserContent(
parts=media_parts + [types.Part.from_text(text=padded_prompt)]
)
]
else:
contents = padded_prompt
@@ -891,10 +972,22 @@ class SSL_GeminiTextPrompt(IO.ComfyNode):
model,
padded_system_instruction,
padded_prompt,
video_hash,
video_mime_type,
video_fps,
context_image_hashes,
media_resolution,
)
skip_context_cache = (
use_cache
and video_bytes is not None
and (config.get("use_vertexai_env", False) or config.get("vertexai_express", False))
and context_inline_bytes > cls.VERTEX_CACHE_INLINE_LIMIT_BYTES
)
if skip_context_cache:
print("[WARNING] Vertex context cache skipped because inline cached media exceeds 10 MB; using full request")
generate_content_config = cls._build_generate_content_config(
model=model,
temperature=temperature,
@@ -928,7 +1021,7 @@ class SSL_GeminiTextPrompt(IO.ComfyNode):
result_queue: "queue.Queue[Tuple[str, Any]]" = queue.Queue()
def generate_once():
if not use_cache:
if not use_cache or skip_context_cache:
return client.models.generate_content(
model=model,
contents=contents,
+141 -10
View File
@@ -59,13 +59,41 @@ def _install_success_client(monkeypatch, captured):
gemini_nodes.SSL_GeminiTextPrompt._client_cache.clear()
class FakeVideo:
def __init__(self, data):
self.data = data
self.save_calls = []
def save_to(self, buffer, format, codec):
self.save_calls.append((format, codec))
buffer.write(self.data)
def _video_config(data, fps=1):
return {"video": FakeVideo(data), "fps": fps}
def test_image_autogrow_is_last_input():
schema = gemini_nodes.SSL_GeminiTextPrompt.define_schema()
assert schema.inputs[-2].id == "timeout_fallback_text"
assert schema.inputs[-2].id == "video"
assert schema.inputs[-2].optional is True
assert schema.inputs[-1].id == "image_inputs"
def test_video_config_exposes_native_video_and_integer_fps():
schema = gemini_nodes.SSL_GeminiVideoConfig.define_schema()
fps = next(input_ for input_ in schema.inputs if input_.id == "fps")
assert [input_.id for input_ in schema.inputs] == ["video", "fps"]
assert fps.default == 1
assert fps.min == 1
assert fps.max == 24
native_video = object()
output = gemini_nodes.SSL_GeminiVideoConfig.execute(native_video, 2)
assert output[0] == {"video": native_video, "fps": 2}
def test_context_cache_reuses_full_input_after_local_fingerprint_miss(monkeypatch):
captured = {"creates": [], "generations": []}
@@ -112,6 +140,7 @@ def test_context_cache_reuses_full_input_after_local_fingerprint_miss(monkeypatc
kwargs.update(
use_seed=True,
seed=1,
video=_video_config(b"normalized-video", fps=2),
image_inputs={"image_1": torch.full((1, 2, 2, 3), 0.25)},
)
@@ -125,8 +154,11 @@ def test_context_cache_reuses_full_input_after_local_fingerprint_miss(monkeypatc
create_config = captured["creates"][0]["config"]
assert create_config.system_instruction == "test system instruction"
assert create_config.ttl == "900s"
assert create_config.contents[0].parts[0].inline_data.data == b"normalized-video"
assert create_config.contents[0].parts[0].inline_data.mime_type == "video/mp4"
assert create_config.contents[0].parts[0].video_metadata.fps == 2
assert create_config.contents[0].parts[-1].text == "test prompt"
assert len(create_config.contents[0].parts) == 2
assert len(create_config.contents[0].parts) == 3
assert len(captured["generations"]) == 2
assert all(call["contents"] == " " for call in captured["generations"])
assert all(call["config"].cached_content == "cachedContents/test" for call in captured["generations"])
@@ -284,18 +316,117 @@ def test_context_cache_ttl_change_updates_existing_resource():
def test_context_cache_identity_covers_every_cached_input():
build_key = gemini_nodes.SSL_GeminiTextPrompt._build_context_cache_key
base = build_key(("standard", "key-a"), "model-a", "system-a", "prompt-a", ["image-a"], "low")
base = build_key(("standard", "key-a"), "model-a", "system-a", "prompt-a", "video-a", "video/mp4", 1, ["image-a"], "low")
variants = {
build_key(("standard", "key-b"), "model-a", "system-a", "prompt-a", ["image-a"], "low"),
build_key(("standard", "key-a"), "model-b", "system-a", "prompt-a", ["image-a"], "low"),
build_key(("standard", "key-a"), "model-a", "system-b", "prompt-a", ["image-a"], "low"),
build_key(("standard", "key-a"), "model-a", "system-a", "prompt-b", ["image-a"], "low"),
build_key(("standard", "key-a"), "model-a", "system-a", "prompt-a", ["image-b"], "low"),
build_key(("standard", "key-a"), "model-a", "system-a", "prompt-a", ["image-a"], "high"),
build_key(("standard", "key-b"), "model-a", "system-a", "prompt-a", "video-a", "video/mp4", 1, ["image-a"], "low"),
build_key(("standard", "key-a"), "model-b", "system-a", "prompt-a", "video-a", "video/mp4", 1, ["image-a"], "low"),
build_key(("standard", "key-a"), "model-a", "system-b", "prompt-a", "video-a", "video/mp4", 1, ["image-a"], "low"),
build_key(("standard", "key-a"), "model-a", "system-a", "prompt-b", "video-a", "video/mp4", 1, ["image-a"], "low"),
build_key(("standard", "key-a"), "model-a", "system-a", "prompt-a", "video-b", "video/mp4", 1, ["image-a"], "low"),
build_key(("standard", "key-a"), "model-a", "system-a", "prompt-a", "video-a", "video/webm", 1, ["image-a"], "low"),
build_key(("standard", "key-a"), "model-a", "system-a", "prompt-a", "video-a", "video/mp4", 2, ["image-a"], "low"),
build_key(("standard", "key-a"), "model-a", "system-a", "prompt-a", "video-a", "video/mp4", 1, ["image-b"], "low"),
build_key(("standard", "key-a"), "model-a", "system-a", "prompt-a", "video-a", "video/mp4", 1, ["image-a"], "high"),
}
assert base not in variants
assert len(variants) == 6
assert len(variants) == 9
def test_video_is_normalized_sent_first_and_locally_fingerprinted(monkeypatch):
captured = {"generations": []}
class FakeModels:
@staticmethod
def generate_content(**kwargs):
captured["generations"].append(kwargs)
part = SimpleNamespace(text="ok", inline_data=None)
return SimpleNamespace(candidates=[SimpleNamespace(content=SimpleNamespace(parts=[part]))])
class FakeClient:
def __init__(self, **kwargs):
self.models = FakeModels()
monkeypatch.setattr(gemini_nodes.genai, "Client", FakeClient)
gemini_nodes.SSL_GeminiTextPrompt._cache.clear()
gemini_nodes.SSL_GeminiTextPrompt._client_cache.clear()
kwargs = _execute_kwargs(_config(api_key="test-key"))
kwargs.update(
model="gemini-3-flash-preview",
media_resolution="low",
use_seed=True,
seed=7,
video=_video_config(b"video-a", fps=2),
)
first = gemini_nodes.SSL_GeminiTextPrompt.execute(**kwargs)
kwargs["video"] = _video_config(b"video-a", fps=2)
second = gemini_nodes.SSL_GeminiTextPrompt.execute(**kwargs)
kwargs["video"] = _video_config(b"video-b", fps=2)
third = gemini_nodes.SSL_GeminiTextPrompt.execute(**kwargs)
assert first[0] == second[0] == third[0] == "ok"
assert len(captured["generations"]) == 2
parts = captured["generations"][0]["contents"][0].parts
assert parts[0].inline_data.data == b"video-a"
assert parts[0].inline_data.mime_type == "video/mp4"
assert parts[0].media_resolution.level == "MEDIA_RESOLUTION_LOW"
assert parts[-1].text == "test prompt"
assert kwargs["video"]["video"].save_calls == [
(gemini_nodes.Types.VideoContainer.MP4, gemini_nodes.Types.VideoCodec.H264)
]
def test_video_at_inline_limit_returns_clear_error(monkeypatch):
monkeypatch.setattr(gemini_nodes.SSL_GeminiTextPrompt, "VIDEO_INLINE_LIMIT_BYTES", 4)
output = gemini_nodes.SSL_GeminiTextPrompt.execute(
**_execute_kwargs(_config()),
video=_video_config(b"1234"),
)
assert output[0] == "Error processing input video: Gemini video input must be smaller than 100 MB after MP4/H.264 normalization."
def test_vertex_video_over_cache_blob_limit_uses_full_request(monkeypatch):
captured = {"cache_creates": 0, "generations": []}
class FakeCaches:
@staticmethod
def create(**kwargs):
captured["cache_creates"] += 1
raise AssertionError("Vertex cache creation must be skipped")
class FakeModels:
@staticmethod
def generate_content(**kwargs):
captured["generations"].append(kwargs)
part = SimpleNamespace(text="ok", inline_data=None)
return SimpleNamespace(candidates=[SimpleNamespace(content=SimpleNamespace(parts=[part]))])
class FakeClient:
def __init__(self, **kwargs):
self.caches = FakeCaches()
self.models = FakeModels()
monkeypatch.setattr(gemini_nodes.genai, "Client", FakeClient)
monkeypatch.setattr(gemini_nodes.SSL_GeminiTextPrompt, "VERTEX_CACHE_INLINE_LIMIT_BYTES", 1)
gemini_nodes.SSL_GeminiTextPrompt._cache.clear()
gemini_nodes.SSL_GeminiTextPrompt._client_cache.clear()
gemini_nodes.SSL_GeminiTextPrompt._context_cache.clear()
config = _config(api_key="test-key", vertexai_express=True, use_cache=True, cache_seed=11)
output = gemini_nodes.SSL_GeminiTextPrompt.execute(
**_execute_kwargs(config),
use_seed=True,
seed=2,
video=_video_config(b"12"),
)
assert output[0] == "ok"
assert captured["cache_creates"] == 0
assert len(captured["generations"]) == 1
assert captured["generations"][0]["contents"] != " "
assert captured["generations"][0]["config"].seed == 11
def test_vertex_config_socket_ids_remain_stable():
+1
View File
@@ -1,6 +1,7 @@
# Gemini node schema, request construction, caching, and authentication behavior.
[groups.gemini_node]
paths = [
"__init__.py",
"gemini_nodes.py",
]
python_tests = [