V 2.0.0 - Universal api #66 - VEO

This commit is contained in:
DESKTOP-TVBJISQ\Primere
2026-03-06 22:46:34 +01:00
parent de225980da
commit eac48d4dd8
9 changed files with 564 additions and 270 deletions
+47 -13
View File
@@ -24,6 +24,7 @@ from ..components.API import api_json_to_requestbody
from ..components.API import external_api_backend
from ..components.API import api_schema_registry
from ..components import file_output
from server import PromptServer
class PrimereApiProcessor:
CATEGORY = TREE_API
@@ -75,12 +76,13 @@ class PrimereApiProcessor:
hidden_inputs = {
"extra_pnginfo": "EXTRA_PNGINFO",
"prompt_extra": "PROMPT"
"prompt_extra": "PROMPT",
"unique_id": "UNIQUE_ID",
}
return {"required": cls.required_inputs, "optional": cls.optional_inputs, "hidden": hidden_inputs}
def process_uniapi(self, processor, api_provider, api_service, prompt, negative_prompt = None, batch = 1, reference_images = None, first_image = None, last_image = None, width = 1024, height = 1024, aspect_ratio = '1:1', seed = None, debug_mode = False, **kwargs):
def process_uniapi(self, processor, api_provider, api_service, prompt, negative_prompt = None, batch = 1, reference_images = None, first_image = None, last_image = None, width = 1024, height = 1024, aspect_ratio = '1:1', seed = None, debug_mode = False, unique_id = None, **kwargs):
API_SCHEMAS_PATH = os.path.join(PRIMERE_ROOT, 'front_end', 'api_schemas.json')
API_CONFIG_PATH = os.path.join(PRIMERE_ROOT, 'json', 'apiconfig.json')
API_SCHEMA_REGISTRY = api_schema_registry.load_and_validate_api_schema_registry(API_SCHEMAS_PATH, API_CONFIG_PATH)
@@ -188,6 +190,20 @@ class PrimereApiProcessor:
elif img_binary_api not in (None, "") and (not isinstance(img_binary_api, list) or len(img_binary_api) > 0):
selected_parameters["reference_images"] = img_binary_api
if first_image is not None:
first_image_source = first_image
elif reference_images is not None:
first_image_source = reference_images[0] if isinstance(reference_images, list) else reference_images
else:
first_image_source = None
if first_image_source is not None:
first_image_result = external_api_backend.apply_reference_images_handler(
schema, api_provider,
{"img_binary_api": first_image_source, "loaded_client_for_upload": loaded_client_for_upload}
)
if isinstance(first_image_result, dict) and first_image_result:
selected_parameters.update(first_image_result)
possible_parameters = schema.get("possible_parameters", {}) if isinstance(schema, dict) else {}
if isinstance(possible_parameters, dict):
for key in possible_parameters.keys():
@@ -306,6 +322,11 @@ class PrimereApiProcessor:
# --- File naming and output path resolution ---
auto_save_result = kwargs.get('auto_save_result', False)
if auto_save_result and result_image is not None:
if type(result_image).__name__ == "str":
SAVED_IMAGE_PATH = os.path.join(PRIMERE_ROOT, 'front_end', 'images')
SAVED_IMAGE = os.path.join(SAVED_IMAGE_PATH, "file_saved.jpg")
result_image = utility.ImageLoaderFromPath(SAVED_IMAGE)
output_path_input = kwargs.get('output_path', '[time(%Y-%m-%d)]')
subpath = kwargs.get('subpath', 'None')
add_provider_to_path = kwargs.get('add_provider_to_path', False)
@@ -350,17 +371,30 @@ class PrimereApiProcessor:
)
Path(folder_paths.temp_directory).mkdir(parents=True, exist_ok=True)
file_output.save_bytes_to_file(save_bytes, output_file, image_extension, image_quality, folder_paths.temp_directory)
try:
saved_path = file_output.save_bytes_to_file(save_bytes, output_file, image_extension, image_quality, folder_paths.temp_directory)
save_data = {
"provider": api_provider,
"service": selected_service or api_service,
"selected_parameters": selected_parameters_output,
"used_values": used_values_output,
# "rendered": rendered_payload,
"raw_payload": raw_payload,
# "api_result": api_result_debug,
}
file_output.save_metadata(save_data, json_file, txt_file, save_data_to_json, save_data_to_txt, used_values_output)
save_data = {
"provider": api_provider,
"service": selected_service or api_service,
"selected_parameters": selected_parameters_output,
"used_values": used_values_output,
# "rendered": rendered_payload,
"raw_payload": raw_payload,
# "api_result": api_result_debug,
}
file_output.save_metadata(save_data, json_file, txt_file, save_data_to_json, save_data_to_txt, used_values_output)
PromptServer.instance.send_sync("primere.save_result", {
"node_id": unique_id,
"status": "success",
"path": saved_path,
})
except Exception as save_error:
PromptServer.instance.send_sync("primere.save_result", {
"node_id": unique_id,
"status": "failed",
"error": str(save_error),
})
return (result_image, client, api_provider, schema, rendered_payload, raw_payload, used_values_output, api_schemas, api_result_debug)
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -811,7 +811,7 @@ File saving only runs when `auto_save_result` is ON and the API returned a valid
| Input | Default | Description |
|---|---|---|
| `output_path` | `[time(%Y-%m-%d)]` | Base output directory. Supports `[time(...)]` tokens. Relative paths are anchored to the ComfyUI output directory. Absolute paths are used as-is. |
| `output_path` | `[time(%Y-%m-%d)]` | Base output directory. Supports `[time(...)]` tokens. Relative paths are anchored to the ComfyUI output directory. Absolute paths are used as-is — e.g. `e:\!Works\AI\AIPICS\ComfyAPI\[time(%Y-%m-Week-%W)]` saves outside ComfyUI to any location on disk. |
| `subpath` | `Project` | Fixed subdirectory appended after provider/service/model dirs. Select `None` to skip. |
| `add_provider_to_path` | OFF | Adds the API provider name as a subdirectory (e.g. `Gemini`). |
| `add_service_to_path` | OFF | Adds the selected service name as a subdirectory (e.g. `Imagen`). |
@@ -855,6 +855,9 @@ The actual file type is detected from the API response bytes (MIME detection), n
| `text/*` | `.txt` (UTF-8) |
| other / unknown | extension from `image_extension` |
> **Important — non-image results have no display or player.**
> Video, audio, and text results are saved to disk only. There is no preview, no image output, and no playback inside ComfyUI. When the file is saved successfully, a toast notification appears on the node with the saved file path. If `auto_save_result` is **OFF**, the result is permanently lost — it exists only in memory for the duration of that execution and is not recoverable afterward.
### Metadata files
| Input | Default | Description |
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
import io
from typing import Any
import numpy as np
from PIL import Image
from google.genai import types
def handle_reference_images(img_binary_api: Any = None, temp_file_ref: str = "", loaded_client_for_upload: Any = None, **_: Any):
if img_binary_api is None:
return {}
if type(img_binary_api).__name__ == "Tensor":
img_array = (img_binary_api[0].cpu().numpy() * 255).astype(np.uint8)
pil_image = Image.fromarray(img_array)
buffer = io.BytesIO()
pil_image.save(buffer, format="PNG")
image_bytes = buffer.getvalue()
return {"first_image": types.Image(image_bytes=image_bytes, mime_type="image/png")}
return {}
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
import time
import os
import random
import folder_paths
def handle_response(api_result, schema=None, loaded_client=None, response_url=None, client=None, sdk_context=None):
if api_result is None:
return [None, None]
operation = api_result
while not operation.done:
time.sleep(10)
operation = client.operations.get(operation)
generated_videos = getattr(getattr(operation, 'response', None), 'generated_videos', None)
if not generated_videos:
return [None, None]
generated_video = generated_videos[0]
tmp_path = os.path.join(folder_paths.temp_directory, f"veo_{random.randint(10000, 99999)}.mp4")
client.files.download(file=generated_video.video)
generated_video.video.save(tmp_path)
with open(tmp_path, 'rb') as f:
video_bytes = f.read()
return ["video_result", video_bytes]
+11 -8
View File
@@ -68,16 +68,17 @@
"VEO": {
"provider": "Gemini",
"service": "VEO",
"reference_images_handler": "Gemini_VEO.py",
"import_modules": [
"from google import genai",
"from google.genai import types"
],
"possible_parameters": {
"aspect_ratio": ["9:16", "16:9"],
"durationSeconds": ["4", "6", "8"],
"personGeneration": ["allow_all", "allow_adult", "dont_allow"],
"resolution": ["720p", "1080p", "4K"],
"model": ["veo-3.0-generate", "veo-3.0-fast-generate", "veo-3.1-generate-preview", "veo-3.1-generate", "veo-3.1-fast-generate-preview", "veo-3.1-fast-generate"]
"model": ["veo-2.0-generate-001", "veo-3.1-fast-generate-preview", "veo-3.1-generate-preview"],
"aspect_ratio": ["16:9", "16:10", "9:16", "10:16"],
"duration_seconds": [4, 5, 6, 7, 8],
"person_generation": ["allow_adult", "allow_all", "dont_allow"],
"resolution": ["720p", "1080p", "4K"]
},
"request": {
"method": "SDK",
@@ -93,11 +94,13 @@
"$args": [],
"$kwargs": {
"aspectRatio": "{{aspect_ratio}}",
"number_of_videos": "{{batch}}",
"resolution": "{{config_resolution}}",
"durationSeconds": "{{durationSeconds}}",
"personGeneration": "{{personGeneration}}",
"durationSeconds": "{{duration_seconds}}",
"personGeneration": "{{person_generation}}",
"reference_images": "{{reference_images}}",
"last_frame": "{{last_image}}"
"last_frame": "{{last_image}}",
"negative_prompt": "{{negative_prompt}}"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

+44 -38
View File
@@ -389,46 +389,52 @@ class PreviewSaver {
saveImageName = state.targetFileName;
}
const renderToCanvas = (blob) => new Promise((resolve, reject) => {
const blobUrl = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(blobUrl);
const canvas = document.createElement('canvas');
let w = img.naturalWidth;
let h = img.naturalHeight;
if (maxWidth && w > maxWidth) { h = Math.round(h * maxWidth / w); w = maxWidth; }
if (maxHeight && h > maxHeight) { w = Math.round(w * maxHeight / h); h = maxHeight; }
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, w, h);
resolve(canvas);
};
img.onerror = () => {
URL.revokeObjectURL(blobUrl);
reject(new Error('Failed to decode image: ' + imageName));
};
img.src = blobUrl;
});
fetch(imageSource)
.then((res) => res.blob())
.then((blob) => {
const reader = new FileReader();
reader.onloadend = () => {
const file = dataURLtoFile(reader.result, imageName);
loadImage(file, function (img) {
if (typeof img.toDataURL === "function") {
const resampledImage = img.toDataURL(imgMime, wv.targetQuality);
if (wv.saveMode === false) {
downloadImage(resampledImage, extension, saveImageName);
} else {
sendPOSTmessage(JSON.stringify({
"PreviewTarget": wv.previewTarget,
"PreviewTargetOriginal": state.selectedTarget,
"extension": extension,
"ImageName": imageName,
"ImagePath": ImagePath,
"SaveImageName": saveImageName,
"maxWidth": img.width,
"maxHeight": img.height,
"TargetQuality": wv.targetQuality,
"PrwSaveMode": wv.prwSaveMode,
}));
}
} else {
alert('Source image: ' + imageName + ' does not exist, maybe deleted.');
}
}, {
maxWidth: maxWidth,
maxHeight: maxHeight,
canvas: true,
pixelRatio: 1,
downsamplingRatio: wv.targetQuality / 100,
orientation: true,
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high',
});
};
reader.readAsDataURL(blob);
.then((blob) => renderToCanvas(blob))
.then((canvas) => {
const resampledImage = canvas.toDataURL(imgMime, wv.targetQuality / 100);
if (wv.saveMode === false) {
downloadImage(resampledImage, extension, saveImageName);
} else {
sendPOSTmessage(JSON.stringify({
"PreviewTarget": wv.previewTarget,
"PreviewTargetOriginal": state.selectedTarget,
"extension": extension,
"ImageName": imageName,
"ImagePath": ImagePath,
"SaveImageName": saveImageName,
"maxWidth": canvas.width,
"maxHeight": canvas.height,
"TargetQuality": wv.targetQuality,
"PrwSaveMode": wv.prwSaveMode,
}));
}
})
.catch((err) => {
alert('Failed to load source image: ' + err.message);
+116
View File
@@ -6,6 +6,106 @@ const SCHEMA_URL = new URL("/extensions/ComfyUI_Primere_Nodes/api_schemas.json",
let schemaCache = null;
let schemaPromise = null;
const TOAST_DURATION_MS = 10000; // toast auto-dismiss delay in milliseconds
function showToast(status, message) {
let container = document.getElementById("primere-toast-container");
if (!container) {
container = document.createElement("div");
container.id = "primere-toast-container";
Object.assign(container.style, {
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: "99999",
display: "flex",
flexDirection: "column",
gap: "8px",
pointerEvents: "none",
});
document.body.appendChild(container);
}
const isSuccess = status === "success";
const accentColor = isSuccess ? "#4caf50" : "#f44336";
const icon = isSuccess ? "✔" : "✖";
const toast = document.createElement("div");
Object.assign(toast.style, {
position: "relative",
overflow: "hidden",
background: "#1e1e1e",
border: `1px solid ${accentColor}`,
borderLeft: `4px solid ${accentColor}`,
borderRadius: "6px",
padding: "10px 14px 10px 12px",
minWidth: "280px",
maxWidth: "440px",
display: "flex",
alignItems: "flex-start",
gap: "10px",
pointerEvents: "all",
boxShadow: "0 4px 16px rgba(0,0,0,0.6)",
opacity: "0",
transform: "translateY(-12px)",
transition: "opacity 0.25s ease, transform 0.25s ease",
fontFamily: "sans-serif",
fontSize: "15px",
color: "#e0e0e0",
cursor: "default",
});
const iconEl = document.createElement("span");
iconEl.textContent = icon;
Object.assign(iconEl.style, { color: accentColor, fontSize: "15px", flexShrink: "0", lineHeight: "1.5" });
const textEl = document.createElement("span");
textEl.textContent = message;
Object.assign(textEl.style, { flex: "1", lineHeight: "1.5", wordBreak: "break-all" });
const closeBtn = document.createElement("button");
closeBtn.textContent = "✕";
Object.assign(closeBtn.style, {
background: "none", border: "none", color: "#888", cursor: "pointer",
fontSize: "13px", padding: "0 0 0 6px", flexShrink: "0", lineHeight: "1.5",
});
closeBtn.addEventListener("mouseover", () => { closeBtn.style.color = "#ccc"; });
closeBtn.addEventListener("mouseout", () => { closeBtn.style.color = "#888"; });
const progress = document.createElement("div");
Object.assign(progress.style, {
position: "absolute", bottom: "0", left: "0",
height: "3px", width: "100%",
background: accentColor,
transformOrigin: "left",
transform: "scaleX(1)",
transition: `transform ${TOAST_DURATION_MS / 1000}s linear`,
});
toast.appendChild(iconEl);
toast.appendChild(textEl);
toast.appendChild(closeBtn);
toast.appendChild(progress);
container.appendChild(toast);
requestAnimationFrame(() => requestAnimationFrame(() => {
toast.style.opacity = "1";
toast.style.transform = "translateY(0)";
progress.style.transform = "scaleX(0)";
}));
function dismiss() {
clearTimeout(timer);
toast.style.opacity = "0";
toast.style.transform = "translateY(-12px)";
setTimeout(() => toast.remove(), 280);
}
const timer = setTimeout(dismiss, TOAST_DURATION_MS);
closeBtn.addEventListener("click", dismiss);
}
async function loadSchemas() {
if (schemaCache) {
return schemaCache;
@@ -350,6 +450,22 @@ async function initializeUniApiNode(node) {
ctx.restore();
};
const onSaveResult = (event) => {
const detail = event.detail;
if (String(detail?.node_id) !== String(node.id)) return;
if (detail.status === "success") {
showToast("success", `Saved: ${detail.path}`);
} else {
showToast("error", `Save failed: ${detail.error}`);
}
};
app.api.addEventListener("primere.save_result", onSaveResult);
const originalOnRemoved = node.onRemoved;
node.onRemoved = function () {
originalOnRemoved?.call(this);
app.api.removeEventListener("primere.save_result", onSaveResult);
};
const originalOnWidgetChanged = node.onWidgetChanged;
node.onWidgetChanged = function (name, value, oldValue, widget) {