commit 263cb060eeaf8782aeaf6c33ffb67f042d34f70a Author: Pharma-Lobby Date: Fri Jul 10 16:06:22 2026 +0000 ComfyUI-Replicate-Select v0.1.0 — 14 kuratierte Modelle + ReplicateCustom Bild (Seedream 4.5, Nano Banana Pro), Video (Kling v3, Veo 3.1, Seedance 2.0 Fast), Upscale (SUPIR, Real-ESRGAN), TTS (MiniMax 2.8 HD, Inworld TTS 2, ElevenLabs v3), Lipsync (VEED Fabric, Pruna Avatar, Sync 2 Pro, HeyGen Precision). Architektur nach Runware-Vorbild: nodes.json + Generator + Escape-Hatch. Katalog-Refresh ohne Token via scripts/build_catalog.py (public Schema-Seiten). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01K33rz4XBNkq2gKoQov5D37 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1966835 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# ComfyUI-Replicate-Select + +Kuratierte Replicate-Modelle als ComfyUI-Nodes — schlank statt Katalog-Flut. +Architektur nach dem Vorbild von [ComfyUI-Runware](https://github.com/Runware/ComfyUI-Runware): +`nodes.json` (Katalog) + Generator + `ReplicateCustom` als Escape-Hatch. + +## Modelle (14) + +| Kategorie | Nodes | +|---|---| +| image | Seedream 4.5 · Nano Banana Pro | +| video | Kling v3 Video · Veo 3.1 · Seedance 2.0 Fast | +| upscale | SUPIR · Real-ESRGAN | +| tts | MiniMax Speech 2.8 HD · Inworld TTS 2 · ElevenLabs v3 | +| lipsync | VEED Fabric 1.0 · Pruna Video Avatar · Sync Lipsync 2 Pro · HeyGen Lipsync Precision | + +Dazu: **Replicate Custom** — beliebige Modell-ID + JSON-Input. + +## Auth + +`REPLICATE_API_TOKEN` als Umgebungsvariable (auf Modal: Secret `mcp-keys`). +Kein Key im Node, keine Key-Dateien. + +## Outputs + +- **image** → IMAGE-Tensor (direkt weiterverdrahtbar) +- **audio** → AUDIO (ComfyUI-nativ, z.B. → SaveAudio oder → Lipsync-Nodes via Upload) +- **video** → MP4 im Output-Ordner, Node gibt den Pfad als STRING zurück + +## Katalog aktualisieren / Modelle hinzufügen + +```bash +# Modell in scripts/build_catalog.py → META eintragen, dann: +python scripts/build_catalog.py +``` + +Zieht die Input-Schemas von den öffentlichen Replicate-Schema-Seiten — kein Token nötig. + +## Hinweise + +- Bild-Inputs werden als PNG hochgeladen (klein → data-URI, groß → Replicate Files-API) +- Audio-Inputs (Lipsync) akzeptieren ComfyUI-AUDIO und werden als WAV hochgeladen +- Video-Inputs erwarten eine URL (STRING) — Replicate braucht abrufbare Dateien diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..39a8c6b --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/__pycache__/nodes.cpython-311.pyc b/__pycache__/nodes.cpython-311.pyc new file mode 100644 index 0000000..d957b89 Binary files /dev/null and b/__pycache__/nodes.cpython-311.pyc differ diff --git a/nodes.json b/nodes.json new file mode 100644 index 0000000..cca7a96 --- /dev/null +++ b/nodes.json @@ -0,0 +1,1486 @@ +{ + "models": [ + { + "id": "bytedance/seedream-4.5", + "name": "Seedream 4.5", + "category": "image", + "output": "image", + "inputs": [ + { + "name": "prompt", + "order": 0, + "required": true, + "description": "Text prompt for image generation. Maximum 4000 characters. BytePlus recommends keeping prompts under 600 English words for best results.", + "kind": "string", + "default": "", + "multiline": true + }, + { + "name": "image_input", + "order": 1, + "required": false, + "description": "Input image(s) for image-to-image generation. List of 1-14 images for single or multi-reference generation.", + "kind": "image", + "is_list": true + }, + { + "name": "size", + "order": 2, + "required": false, + "description": "Image resolution: 2K (2048px), 4K (4096px), or 'custom' for specific dimensions. Note: 1K resolution is not supported in Seedream 4.5.", + "kind": "combo", + "options": [ + "2K", + "4K", + "custom" + ], + "default": "2K" + }, + { + "name": "aspect_ratio", + "order": 3, + "required": false, + "description": "Image aspect ratio. Only used when size is not 'custom'. Use 'match_input_image' to automatically match the input image's aspect ratio.", + "kind": "combo", + "options": [ + "match_input_image", + "1:1", + "4:3", + "3:4", + "4:5", + "5:4", + "16:9", + "9:16", + "3:2", + "2:3", + "21:9", + "9:21" + ], + "default": "match_input_image" + }, + { + "name": "width", + "order": 4, + "required": false, + "description": "Custom image width (only used when size='custom'). Range: 1024-4096 pixels.", + "kind": "int", + "default": 2048, + "min": 1024, + "max": 4096 + }, + { + "name": "height", + "order": 5, + "required": false, + "description": "Custom image height (only used when size='custom'). Range: 1024-4096 pixels.", + "kind": "int", + "default": 2048, + "min": 1024, + "max": 4096 + }, + { + "name": "sequential_image_generation", + "order": 6, + "required": false, + "description": "Group image generation mode. 'disabled' generates a single image. 'auto' lets the model decide whether to generate multiple related images (e.g., story scenes, character variations).", + "kind": "combo", + "options": [ + "disabled", + "auto" + ], + "default": "disabled" + }, + { + "name": "max_images", + "order": 7, + "required": false, + "description": "Maximum number of images to generate when sequential_image_generation='auto'. Range: 1-15. Total images (input + generated) cannot exceed 15.", + "kind": "int", + "default": 1, + "min": 1, + "max": 15 + }, + { + "name": "disable_safety_checker", + "order": 8, + "required": false, + "description": "Disable the safety checker for generated images. When enabled, input moderation is relaxed to only block illegal content (CSAM). Use responsibly.", + "kind": "bool", + "default": false + } + ] + }, + { + "id": "google/nano-banana-pro", + "name": "Nano Banana Pro", + "category": "image", + "output": "image", + "inputs": [ + { + "name": "prompt", + "order": 0, + "required": true, + "description": "A text description of the image you want to generate", + "kind": "string", + "default": "", + "multiline": true + }, + { + "name": "image_input", + "order": 1, + "required": false, + "description": "Input images to transform or use as reference (supports up to 14 images)", + "kind": "image", + "is_list": true + }, + { + "name": "aspect_ratio", + "order": 2, + "required": false, + "description": "Aspect ratio of the generated image", + "kind": "combo", + "options": [ + "match_input_image", + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9" + ], + "default": "match_input_image" + }, + { + "name": "resolution", + "order": 3, + "required": false, + "description": "Resolution of the generated image", + "kind": "combo", + "options": [ + "1K", + "2K", + "4K" + ], + "default": "2K" + }, + { + "name": "output_format", + "order": 4, + "required": false, + "description": "Format of the output image", + "kind": "combo", + "options": [ + "jpg", + "png" + ], + "default": "jpg" + }, + { + "name": "safety_filter_level", + "order": 5, + "required": false, + "description": "block_low_and_above is strictest, block_medium_and_above blocks some prompts, block_only_high is most permissive but some prompts will still be blocked", + "kind": "combo", + "options": [ + "block_low_and_above", + "block_medium_and_above", + "block_only_high" + ], + "default": "block_only_high" + }, + { + "name": "allow_fallback_model", + "order": 6, + "required": false, + "description": "Fallback to another model (currently bytedance/seedream-5) if Nano Banana Pro is at capacity.", + "kind": "bool", + "default": false + } + ] + }, + { + "id": "kwaivgi/kling-v3-video", + "name": "Kling v3 Video", + "category": "video", + "output": "video", + "inputs": [ + { + "name": "prompt", + "order": 0, + "required": true, + "description": "Text prompt for video generation. Max 2500 characters.", + "kind": "string", + "default": "", + "multiline": true + }, + { + "name": "negative_prompt", + "order": 1, + "required": false, + "description": "Things you do not want to see in the video. Max 2500 characters.", + "kind": "string", + "default": "", + "multiline": true + }, + { + "name": "start_image", + "order": 2, + "required": false, + "description": "First frame image. Supports .jpg/.jpeg/.png, max 10MB, min 300px, aspect ratio 1:2.5 to 2.5:1.", + "kind": "image", + "is_list": false + }, + { + "name": "end_image", + "order": 3, + "required": false, + "description": "Last frame image. Requires start_image. Supports .jpg/.jpeg/.png, max 10MB, min 300px.", + "kind": "image", + "is_list": false + }, + { + "name": "mode", + "order": 4, + "required": false, + "description": "'standard' generates 720p, 'pro' generates 1080p, '4k' generates 4K.", + "kind": "combo", + "options": [ + "standard", + "pro", + "4k" + ], + "default": "pro" + }, + { + "name": "aspect_ratio", + "order": 5, + "required": false, + "description": "Aspect ratio. Ignored when start_image is provided.", + "kind": "combo", + "options": [ + "16:9", + "9:16", + "1:1" + ], + "default": "16:9" + }, + { + "name": "duration", + "order": 6, + "required": false, + "description": "Video duration in seconds.", + "kind": "int", + "default": 5, + "min": 3, + "max": 15 + }, + { + "name": "generate_audio", + "order": 7, + "required": false, + "description": "Generate native audio for the video.", + "kind": "bool", + "default": false + }, + { + "name": "multi_prompt", + "order": 8, + "required": false, + "description": "JSON array of shot definitions for multi-shot mode. Each shot: {\"prompt\": \"...\", \"duration\": N}. Max 6 shots, min 1s per shot, total must equal duration.", + "kind": "string", + "default": "", + "multiline": true + } + ] + }, + { + "id": "google/veo-3.1", + "name": "Veo 3.1", + "category": "video", + "output": "video", + "inputs": [ + { + "name": "prompt", + "order": 0, + "required": true, + "description": "Text prompt for video generation", + "kind": "string", + "default": "", + "multiline": true + }, + { + "name": "aspect_ratio", + "order": 1, + "required": false, + "description": "Video aspect ratio", + "kind": "combo", + "options": [ + "16:9", + "9:16" + ], + "default": "16:9" + }, + { + "name": "duration", + "order": 2, + "required": false, + "description": "Video duration in seconds", + "kind": "combo", + "options": [ + 4, + 6, + 8 + ], + "default": 8 + }, + { + "name": "image", + "order": 3, + "required": false, + "description": "Input image to start generating from. Ideal images are 16:9 or 9:16 and 1280x720 or 720x1280, depending on the aspect ratio you choose.", + "kind": "image", + "is_list": false + }, + { + "name": "last_frame", + "order": 4, + "required": false, + "description": "Ending image for interpolation. When provided with an input image, creates a transition between the two images.", + "kind": "image", + "is_list": false + }, + { + "name": "reference_images", + "order": 5, + "required": false, + "description": "1 to 3 reference images for subject-consistent generation (reference-to-video, or R2V). Reference images only work with 16:9 aspect ratio and 8-second duration. Last frame is ignored if reference imag", + "kind": "image", + "is_list": true + }, + { + "name": "negative_prompt", + "order": 6, + "required": false, + "description": "Description of what to exclude from the generated video", + "kind": "string", + "default": "", + "multiline": true + }, + { + "name": "resolution", + "order": 7, + "required": false, + "description": "Resolution of the generated video", + "kind": "combo", + "options": [ + "720p", + "1080p" + ], + "default": "1080p" + }, + { + "name": "generate_audio", + "order": 8, + "required": false, + "description": "Generate audio with the video", + "kind": "bool", + "default": true + }, + { + "name": "seed", + "order": 9, + "required": false, + "description": "Random seed. Omit for random generations", + "kind": "int", + "default": 0, + "min": -2147483648, + "max": 2147483647 + } + ] + }, + { + "id": "bytedance/seedance-2.0-fast", + "name": "Seedance 2.0 Fast", + "category": "video", + "output": "video", + "inputs": [ + { + "name": "prompt", + "order": 0, + "required": true, + "description": "Text prompt for video generation", + "kind": "string", + "default": "", + "multiline": true + }, + { + "name": "image", + "order": 1, + "required": false, + "description": "Input image for image-to-video generation (first frame). Cannot be combined with reference images.", + "kind": "image", + "is_list": false + }, + { + "name": "last_frame_image", + "order": 2, + "required": false, + "description": "Input image for last frame generation. Only works if a first frame image is also provided. Cannot be combined with reference images.", + "kind": "image", + "is_list": false + }, + { + "name": "reference_images", + "order": 3, + "required": false, + "description": "Reference images (up to 9) for character consistency, style guidance, and scene composition. Cannot be used together with first/last frame images. You can reference them in your prompt as [Image1], [I", + "kind": "image", + "is_list": true + }, + { + "name": "reference_videos", + "order": 4, + "required": false, + "description": "Reference videos (up to 3, total duration max 15s) for motion transfer, style reference, and editing. Reference them in your prompt as [Video1], [Video2], etc.", + "kind": "uri_string" + }, + { + "name": "reference_audios", + "order": 5, + "required": false, + "description": "Reference audio files (up to 3, total duration max 15s) for audio-driven generation and lip-sync. Requires at least one reference image or video. Reference them in your prompt as [Audio1], [Audio2], e", + "kind": "audio" + }, + { + "name": "duration", + "order": 6, + "required": false, + "description": "Video duration in seconds. Set to -1 for intelligent duration (model picks the best length).", + "kind": "int", + "default": 5, + "min": -1, + "max": 15 + }, + { + "name": "resolution", + "order": 7, + "required": false, + "description": "Video resolution.", + "kind": "combo", + "options": [ + "480p", + "720p" + ], + "default": "720p" + }, + { + "name": "aspect_ratio", + "order": 8, + "required": false, + "description": "Video aspect ratio. Set to 'adaptive' to let the model choose the best ratio based on inputs.", + "kind": "combo", + "options": [ + "16:9", + "4:3", + "1:1", + "3:4", + "9:16", + "21:9", + "9:21", + "adaptive" + ], + "default": "16:9" + }, + { + "name": "generate_audio", + "order": 9, + "required": false, + "description": "Generate synchronized audio with the video, including dialogue (use double quotes in prompt), sound effects, and background music.", + "kind": "bool", + "default": true + }, + { + "name": "seed", + "order": 10, + "required": false, + "description": "Random seed. Set for reproducible generation.", + "kind": "int", + "default": 0, + "min": -2147483648, + "max": 2147483647 + } + ] + }, + { + "id": "cjwbw/supir", + "name": "SUPIR Upscale", + "category": "upscale", + "output": "image", + "inputs": [ + { + "name": "image", + "order": 1, + "required": true, + "description": "Low quality input image.", + "kind": "image", + "is_list": false + }, + { + "name": "model_name", + "order": 0, + "required": false, + "description": "Choose a model. SUPIR-v0Q is the default training settings with paper. SUPIR-v0F is high generalization and high image quality in most cases. Training with light degradation settings. Stage1 encoder o", + "kind": "combo", + "options": [ + "SUPIR-v0Q", + "SUPIR-v0F" + ], + "default": "SUPIR-v0Q" + }, + { + "name": "upscale", + "order": 2, + "required": false, + "description": "Upsampling ratio of given inputs.", + "kind": "int", + "default": 1, + "min": -2147483648, + "max": 2147483647 + }, + { + "name": "min_size", + "order": 3, + "required": false, + "description": "Minimum resolution of output images.", + "kind": "float", + "default": 1024, + "min": -1000000000.0, + "max": 1000000000.0 + }, + { + "name": "edm_steps", + "order": 4, + "required": false, + "description": "Number of steps for EDM Sampling Schedule.", + "kind": "int", + "default": 50, + "min": 1, + "max": 500 + }, + { + "name": "use_llava", + "order": 5, + "required": false, + "description": "Use LLaVA model to get captions.", + "kind": "bool", + "default": true + }, + { + "name": "a_prompt", + "order": 6, + "required": false, + "description": "Additive positive prompt for the inputs.", + "kind": "string", + "default": "Cinematic, High Contrast, highly detailed, taken using a Canon EOS R camera, hyper detailed photo - realistic maximum detail, 32k, Color Grading, ultra HD, extreme meticulous detailing, skin pore detailing, hyper sharpness, perfect without deformations.", + "multiline": true + }, + { + "name": "n_prompt", + "order": 7, + "required": false, + "description": "Negative prompt for the inputs.", + "kind": "string", + "default": "painting, oil painting, illustration, drawing, art, sketch, oil painting, cartoon, CG Style, 3D render, unreal engine, blurring, dirty, messy, worst quality, low quality, frames, watermark, signature, jpeg artifacts, deformed, lowres, over-smooth", + "multiline": true + }, + { + "name": "color_fix_type", + "order": 8, + "required": false, + "description": "Color Fixing Type..", + "kind": "combo", + "options": [ + "None", + "AdaIn", + "Wavelet" + ], + "default": "Wavelet" + }, + { + "name": "s_stage1", + "order": 9, + "required": false, + "description": "Control Strength of Stage1 (negative means invalid).", + "kind": "int", + "default": -1, + "min": -2147483648, + "max": 2147483647 + }, + { + "name": "s_churn", + "order": 10, + "required": false, + "description": "Original churn hy-param of EDM.", + "kind": "float", + "default": 5, + "min": -1000000000.0, + "max": 1000000000.0 + }, + { + "name": "s_noise", + "order": 11, + "required": false, + "description": "Original noise hy-param of EDM.", + "kind": "float", + "default": 1.003, + "min": -1000000000.0, + "max": 1000000000.0 + }, + { + "name": "s_cfg", + "order": 12, + "required": false, + "description": " Classifier-free guidance scale for prompts.", + "kind": "float", + "default": 7.5, + "min": 1, + "max": 20 + }, + { + "name": "s_stage2", + "order": 13, + "required": false, + "description": "Control Strength of Stage2.", + "kind": "float", + "default": 1, + "min": -1000000000.0, + "max": 1000000000.0 + }, + { + "name": "linear_CFG", + "order": 14, + "required": false, + "description": "Linearly (with sigma) increase CFG from 'spt_linear_CFG' to s_cfg.", + "kind": "bool", + "default": false + }, + { + "name": "linear_s_stage2", + "order": 15, + "required": false, + "description": "Linearly (with sigma) increase s_stage2 from 'spt_linear_s_stage2' to s_stage2.", + "kind": "bool", + "default": false + }, + { + "name": "spt_linear_CFG", + "order": 16, + "required": false, + "description": "Start point of linearly increasing CFG.", + "kind": "float", + "default": 1, + "min": -1000000000.0, + "max": 1000000000.0 + }, + { + "name": "spt_linear_s_stage2", + "order": 17, + "required": false, + "description": "Start point of linearly increasing s_stage2.", + "kind": "float", + "default": 0, + "min": -1000000000.0, + "max": 1000000000.0 + }, + { + "name": "seed", + "order": 18, + "required": false, + "description": "Random seed. Leave blank to randomize the seed", + "kind": "int", + "default": 0, + "min": -2147483648, + "max": 2147483647 + } + ] + }, + { + "id": "cjwbw/real-esrgan", + "name": "Real-ESRGAN Upscale", + "category": "upscale", + "output": "image", + "inputs": [ + { + "name": "image", + "order": 0, + "required": true, + "description": "Input image", + "kind": "image", + "is_list": false + }, + { + "name": "upscale", + "order": 1, + "required": false, + "description": "Upscaling factor", + "kind": "combo", + "options": [ + 2, + 4, + 8 + ], + "default": 4 + } + ] + }, + { + "id": "minimax/speech-2.8-hd", + "name": "MiniMax Speech 2.8 HD", + "category": "tts", + "output": "audio", + "inputs": [ + { + "name": "text", + "order": 0, + "required": true, + "description": "Text to narrate (max 10,000 characters). Use markers like <#0.5#> to insert pauses in seconds.", + "kind": "string", + "default": "", + "multiline": true + }, + { + "name": "voice_id", + "order": 1, + "required": false, + "description": "Voice to synthesize. Pick any MiniMax system voice (e.g. English_Wiselady, English_Deep-VoicedGentleman) or a voice_id returned by https://replicate.com/minimax/voice-cloning. See the full list of voi", + "kind": "string", + "default": "English_Wiselady" + }, + { + "name": "speed", + "order": 2, + "required": false, + "description": "Speech speed multiplier (0.5–2.0). Lower is slower, higher is faster.", + "kind": "float", + "default": 1, + "min": 0.5, + "max": 2 + }, + { + "name": "volume", + "order": 3, + "required": false, + "description": "Relative loudness. 1.0 is default MiniMax gain. Range 0–10.", + "kind": "float", + "default": 1, + "min": 0, + "max": 10 + }, + { + "name": "pitch", + "order": 4, + "required": false, + "description": "Semitone offset applied to the voice (−12 to +12).", + "kind": "int", + "default": 0, + "min": -12, + "max": 12 + }, + { + "name": "emotion", + "order": 5, + "required": false, + "description": "Desired delivery style. Use auto to let MiniMax choose, or pick a specific emotion.", + "kind": "combo", + "options": [ + "auto", + "happy", + "sad", + "angry", + "fearful", + "disgusted", + "surprised", + "calm", + "fluent", + "neutral" + ], + "default": "auto" + }, + { + "name": "english_normalization", + "order": 6, + "required": false, + "description": "Improve number/date reading for English text (adds a small amount of latency).", + "kind": "bool", + "default": false + }, + { + "name": "sample_rate", + "order": 7, + "required": false, + "description": "Audio sample rate in Hz.", + "kind": "combo", + "options": [ + 8000, + 16000, + 22050, + 24000, + 32000, + 44100 + ], + "default": 32000 + }, + { + "name": "bitrate", + "order": 8, + "required": false, + "description": "MP3 bitrate in bits per second. Only used when audio_format is mp3.", + "kind": "combo", + "options": [ + 32000, + 64000, + 128000, + 256000 + ], + "default": 128000 + }, + { + "name": "audio_format", + "order": 9, + "required": false, + "description": "File format for the generated audio. Choose mp3 for general use, wav/flac for lossless, or pcm for raw bytes.", + "kind": "combo", + "options": [ + "mp3", + "wav", + "flac", + "pcm" + ], + "default": "mp3" + }, + { + "name": "channel", + "order": 10, + "required": false, + "description": "mono for 1 channel (default), stereo for 2 channels.", + "kind": "combo", + "options": [ + "mono", + "stereo" + ], + "default": "mono" + }, + { + "name": "subtitle_enable", + "order": 11, + "required": false, + "description": "Return MiniMax subtitle metadata with sentence timestamps (non-streaming only).", + "kind": "bool", + "default": false + }, + { + "name": "language_boost", + "order": 12, + "required": false, + "description": "Optional language hint. Choose Automatic to let MiniMax detect the language, or pick a specific locale.", + "kind": "combo", + "options": [ + "None", + "Automatic", + "Chinese", + "Chinese,Yue", + "Cantonese", + "English", + "Arabic", + "Russian", + "Spanish", + "French", + "Portuguese", + "German", + "Turkish", + "Dutch", + "Ukrainian", + "Vietnamese", + "Indonesian", + "Japanese", + "Italian", + "Korean", + "Thai", + "Polish", + "Romanian", + "Greek", + "Czech", + "Finnish", + "Hindi", + "Bulgarian", + "Danish", + "Hebrew", + "Malay", + "Persian", + "Slovak", + "Swedish", + "Croatian", + "Filipino", + "Hungarian", + "Norwegian", + "Slovenian", + "Catalan", + "Nynorsk", + "Tamil", + "Afrikaans" + ], + "default": "None" + } + ] + }, + { + "id": "inworld/realtime-tts-2", + "name": "Inworld TTS 2", + "category": "tts", + "output": "audio", + "inputs": [ + { + "name": "text", + "order": 0, + "required": true, + "description": "The text to convert to speech. Maximum 2,000 characters. Supports natural-language steering with bracketed instructions placed before the text they apply to (e.g. `[say excitedly]`, `[whisper in a hus", + "kind": "string", + "default": "", + "multiline": true + }, + { + "name": "voice_id", + "order": 1, + "required": false, + "description": "The voice to use. Use a preset voice name (e.g. 'Ashley', 'Dennis', 'Alex', 'Darlene') or a custom cloned voice ID.", + "kind": "string", + "default": "Ashley" + }, + { + "name": "language", + "order": 2, + "required": false, + "description": "Language of the input text. Use 'auto' to let the model detect the language. Supported production languages: English (en), Chinese (zh), Japanese (ja), Korean (ko), Russian (ru), Italian (it), Spanish", + "kind": "combo", + "options": [ + "auto", + "en", + "zh", + "ja", + "ko", + "ru", + "it", + "es", + "pt", + "fr", + "de", + "pl", + "nl", + "hi", + "he", + "ar" + ], + "default": "auto" + }, + { + "name": "temperature", + "order": 3, + "required": false, + "description": "Controls randomness when generating audio. Higher values produce more expressive results, lower values are more deterministic. Set to 0 to use the model default (1.1).", + "kind": "float", + "default": 0, + "min": 0, + "max": 2 + }, + { + "name": "audio_format", + "order": 4, + "required": false, + "description": "Output audio format.", + "kind": "combo", + "options": [ + "mp3", + "wav", + "ogg_opus", + "flac" + ], + "default": "mp3" + }, + { + "name": "sample_rate", + "order": 5, + "required": false, + "description": "Audio sample rate in Hz.", + "kind": "combo", + "options": [ + 8000, + 16000, + 22050, + 24000, + 32000, + 44100, + 48000 + ], + "default": 48000 + }, + { + "name": "speaking_rate", + "order": 6, + "required": false, + "description": "Speaking speed multiplier. Set to 0 for normal speed (1.0).", + "kind": "float", + "default": 0, + "min": 0, + "max": 1.5 + }, + { + "name": "text_normalization", + "order": 7, + "required": false, + "description": "Controls whether numbers, dates, and abbreviations are expanded before synthesis. 'auto' lets the model decide, 'on' always normalizes, 'off' reads text as-is.", + "kind": "combo", + "options": [ + "auto", + "on", + "off" + ], + "default": "auto" + } + ] + }, + { + "id": "elevenlabs/v3", + "name": "ElevenLabs v3", + "category": "tts", + "output": "audio", + "inputs": [ + { + "name": "prompt", + "order": 0, + "required": true, + "description": "The text to convert to speech", + "kind": "string", + "default": "", + "multiline": true + }, + { + "name": "voice", + "order": 1, + "required": false, + "description": "Voice choice for speech generation", + "kind": "combo", + "options": [ + "Rachel", + "Drew", + "Clyde", + "Paul", + "Aria", + "Domi", + "Dave", + "Roger", + "Fin", + "Sarah", + "James", + "Jane", + "Juniper", + "Arabella", + "Hope", + "Bradford", + "Reginald", + "Gaming", + "Austin", + "Kuon", + "Blondie", + "Priyanka", + "Alexandra", + "Monika", + "Mark", + "Grimblewood" + ], + "default": "Rachel" + }, + { + "name": "stability", + "order": 2, + "required": false, + "description": "Stability setting for voice generation (0.0 to 1.0)", + "kind": "float", + "default": 0.5, + "min": 0, + "max": 1 + }, + { + "name": "similarity_boost", + "order": 3, + "required": false, + "description": "Similarity boost setting (0.0 to 1.0)", + "kind": "float", + "default": 0.75, + "min": 0, + "max": 1 + }, + { + "name": "style", + "order": 4, + "required": false, + "description": "Style exaggeration (0.0 to 1.0)", + "kind": "float", + "default": 0, + "min": 0, + "max": 1 + }, + { + "name": "speed", + "order": 5, + "required": false, + "description": "Speed of speech (0.25 to 4.0)", + "kind": "float", + "default": 1, + "min": 0.7, + "max": 1.2 + }, + { + "name": "previous_text", + "order": 6, + "required": false, + "description": "Previous text for context", + "kind": "string", + "default": "", + "multiline": true + }, + { + "name": "next_text", + "order": 7, + "required": false, + "description": "Next text for context", + "kind": "string", + "default": "", + "multiline": true + }, + { + "name": "language_code", + "order": 8, + "required": false, + "description": "Language code (e.g., 'en', 'es', 'fr')", + "kind": "string", + "default": "en" + } + ] + }, + { + "id": "veed/fabric-1.0", + "name": "VEED Fabric 1.0", + "category": "lipsync", + "output": "video", + "inputs": [ + { + "name": "image", + "order": 0, + "required": true, + "description": "Image to generate video from", + "kind": "image", + "is_list": false + }, + { + "name": "audio", + "order": 1, + "required": true, + "description": "Audio to generate video from", + "kind": "audio" + }, + { + "name": "resolution", + "order": 2, + "required": false, + "description": "Resolution of the generated video", + "kind": "combo", + "options": [ + "480p", + "720p" + ], + "default": "720p" + } + ] + }, + { + "id": "prunaai/p-video-avatar", + "name": "Pruna Video Avatar", + "category": "lipsync", + "output": "video", + "inputs": [ + { + "name": "image", + "order": 0, + "required": true, + "description": "Input image (first frame). Supports jpg, jpeg, png, webp.", + "kind": "image", + "is_list": false + }, + { + "name": "resolution", + "order": 1, + "required": false, + "description": "Resolution of the video.", + "kind": "combo", + "options": [ + "720p", + "1080p" + ], + "default": "720p" + }, + { + "name": "audio", + "order": 2, + "required": false, + "description": "Optional uploaded audio to drive avatar speech. If provided, this is used instead of voice_script and voice settings.", + "kind": "audio" + }, + { + "name": "voice", + "order": 3, + "required": false, + "description": "Voice to use when generating speech from voice_script.", + "kind": "combo", + "options": [ + "Zephyr (Female)", + "Puck (Male)", + "Charon (Male)", + "Kore (Female)", + "Fenrir (Male)", + "Leda (Female)", + "Orus (Male)", + "Aoede (Female)", + "Callirrhoe (Female)", + "Autonoe (Female)", + "Enceladus (Male)", + "Iapetus (Male)", + "Umbriel (Male)", + "Algenib (Male)", + "Despina (Female)", + "Erinome (Female)", + "Laomedeia (Female)", + "Achernar (Female)", + "Algieba (Male)", + "Schedar (Male)", + "Gacrux (Female)", + "Pulcherrima (Female)", + "Achird (Male)", + "Zubenelgenubi (Male)", + "Vindemiatrix (Female)", + "Sadachbia (Male)", + "Sadaltager (Male)", + "Sulafat (Female)", + "Alnilam (Male)", + "Rasalgethi (Male)" + ], + "default": "Zephyr (Female)" + }, + { + "name": "voice_script", + "order": 4, + "required": false, + "description": "Exact words the avatar should say. Required when no audio file is uploaded.", + "kind": "string", + "default": "", + "multiline": true + }, + { + "name": "voice_prompt", + "order": 5, + "required": false, + "description": "Optional style instructions for how to speak voice_script, such as tone, pacing, accent, or emotion. These instructions are not spoken.", + "kind": "string", + "default": "Say the following.", + "multiline": true + }, + { + "name": "voice_language", + "order": 6, + "required": false, + "description": "Language/accent target for generated speech.", + "kind": "combo", + "options": [ + "English (US)", + "English (UK)", + "Spanish", + "French", + "German", + "Italian", + "Portuguese (Brazil)", + "Japanese", + "Korean", + "Hindi" + ], + "default": "English (US)" + }, + { + "name": "seed", + "order": 7, + "required": false, + "description": "Random seed. Set for reproducible generation.", + "kind": "int", + "default": 0, + "min": -2147483648, + "max": 2147483647 + }, + { + "name": "video_prompt", + "order": 8, + "required": false, + "description": "Optional visual prompt describing how the person should appear or behave while speaking.", + "kind": "string", + "default": "The person is talking.", + "multiline": true + }, + { + "name": "negative_prompt", + "order": 9, + "required": false, + "description": "Disabled if empty.Mention what you do NOT want in the video, e.g. \"subtitles, text, blurry, low quality, frames, watermark, titles, scene change\". We recommend using multiple keywords at once.", + "kind": "string", + "default": "", + "multiline": true + }, + { + "name": "strength_negative_prompt", + "order": 10, + "required": false, + "description": "Strength of the Negative Prompt. Optimal value can differ for different video lengths (Experimental Feature)", + "kind": "float", + "default": 0.5, + "min": 0, + "max": 4 + }, + { + "name": "disable_safety_filter", + "order": 11, + "required": false, + "description": "Disable safety filter for prompts and input image. When disabled, prompts are not checked for unsafe content before generation.", + "kind": "bool", + "default": true + }, + { + "name": "disable_prompt_upsampling", + "order": 12, + "required": false, + "description": "When true, skip automatic enhancement of the visual video prompt and use video_prompt directly.", + "kind": "bool", + "default": false + }, + { + "name": "no_op", + "order": 13, + "required": false, + "description": "Health check mode - returns status without inference.", + "kind": "bool", + "default": false + } + ] + }, + { + "id": "sync/lipsync-2-pro", + "name": "Sync Lipsync 2 Pro", + "category": "lipsync", + "output": "video", + "inputs": [ + { + "name": "video", + "order": 0, + "required": true, + "description": "Input video file (.mp4)", + "kind": "uri_string" + }, + { + "name": "audio", + "order": 1, + "required": true, + "description": "Input audio file (.wav)", + "kind": "audio" + }, + { + "name": "sync_mode", + "order": 2, + "required": false, + "description": "Lipsync mode when audio and video durations are out of sync", + "kind": "combo", + "options": [ + "loop", + "bounce", + "cut_off", + "silence", + "remap" + ], + "default": "loop" + }, + { + "name": "temperature", + "order": 3, + "required": false, + "description": "How expressive lipsync can be (0-1)", + "kind": "float", + "default": 0.5, + "min": 0, + "max": 1 + }, + { + "name": "active_speaker", + "order": 4, + "required": false, + "description": "Whether to detect active speaker (i.e. whoever is speaking in the clip will be used for lipsync)", + "kind": "bool", + "default": false + } + ] + }, + { + "id": "heygen/lipsync-precision", + "name": "HeyGen Lipsync Precision", + "category": "lipsync", + "output": "video", + "inputs": [ + { + "name": "video", + "order": 0, + "required": true, + "description": "Source video file to lip-sync.", + "kind": "uri_string" + }, + { + "name": "audio", + "order": 1, + "required": true, + "description": "Replacement audio file. The video's lip movements will be re-animated to match this audio using high-accuracy avatar inference.", + "kind": "audio" + }, + { + "name": "enable_dynamic_duration", + "order": 2, + "required": false, + "description": "Allow the output duration to adjust to match the new audio length.", + "kind": "bool", + "default": true + }, + { + "name": "disable_music_track", + "order": 3, + "required": false, + "description": "Strip background music from the source video.", + "kind": "bool", + "default": false + }, + { + "name": "enable_speech_enhancement", + "order": 4, + "required": false, + "description": "Enhance speech quality in the output.", + "kind": "bool", + "default": false + } + ] + } + ] +} \ No newline at end of file diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..0852c3a --- /dev/null +++ b/nodes.py @@ -0,0 +1,262 @@ +"""ComfyUI-Replicate-Select — kuratierte Replicate-Modelle als ComfyUI-Nodes. + +Architektur nach Runware-Vorbild: nodes.json (Katalog) + Generator + Custom-Node. +Auth: REPLICATE_API_TOKEN aus der Umgebung (Modal-Secret mcp-keys). +Katalog aktualisieren: python scripts/build_catalog.py +""" +import base64 +import io +import json +import os +import time +from pathlib import Path + +import numpy as np +import torch + +CATALOG_PATH = Path(__file__).parent / "nodes.json" +CATEGORY_ROOT = "Replicate Select" +DATA_URI_LIMIT = 200 * 1024 # größere Dateien gehen über die Files-API + + +# ---------- Replicate-Client ---------- + +def _client(): + import replicate + if not os.environ.get("REPLICATE_API_TOKEN"): + raise RuntimeError( + "REPLICATE_API_TOKEN fehlt. In Modal: Secret 'mcp-keys' prüfen; " + "lokal: export REPLICATE_API_TOKEN=r8_..." + ) + return replicate + + +def _upload_bytes(data: bytes, filename: str, content_type: str) -> str: + """Datei zu Replicate hochladen (Files-API) oder als data-URI inline.""" + if len(data) <= DATA_URI_LIMIT: + b64 = base64.b64encode(data).decode() + return f"data:{content_type};base64,{b64}" + import replicate + f = replicate.files.create(io.BytesIO(data), {"filename": filename, "content_type": content_type}) + return f.urls["get"] + + +# ---------- Medien-Konvertierung ---------- + +def _image_to_uri(image_tensor) -> str: + """ComfyUI IMAGE (B,H,W,C float 0-1) → PNG-URI (erstes Bild im Batch).""" + from PIL import Image + arr = (image_tensor[0].cpu().numpy() * 255).clip(0, 255).astype(np.uint8) + img = Image.fromarray(arr) + buf = io.BytesIO() + img.save(buf, format="PNG") + return _upload_bytes(buf.getvalue(), "input.png", "image/png") + + +def _audio_to_uri(audio: dict) -> str: + """ComfyUI AUDIO ({'waveform': (B,C,T), 'sample_rate': int}) → WAV-URI.""" + import torchaudio + buf = io.BytesIO() + waveform = audio["waveform"][0] # (C, T) + torchaudio.save(buf, waveform.cpu(), audio["sample_rate"], format="wav") + return _upload_bytes(buf.getvalue(), "input.wav", "audio/wav") + + +def _download(url_or_file) -> bytes: + """Replicate-Output (FileOutput oder URL-String) → Bytes.""" + if hasattr(url_or_file, "read"): + return url_or_file.read() + import urllib.request + with urllib.request.urlopen(str(url_or_file), timeout=600) as resp: + return resp.read() + + +def _to_image_tensor(data: bytes): + from PIL import Image + img = Image.open(io.BytesIO(data)).convert("RGB") + arr = np.asarray(img).astype(np.float32) / 255.0 + return torch.from_numpy(arr)[None,] + + +def _to_audio(data: bytes) -> dict: + import torchaudio + waveform, sr = torchaudio.load(io.BytesIO(data)) + return {"waveform": waveform[None,], "sample_rate": sr} + + +def _save_video(data: bytes, model_id: str) -> str: + """Video in den ComfyUI-Output-Ordner schreiben, Pfad zurückgeben.""" + try: + import folder_paths + out_dir = Path(folder_paths.get_output_directory()) + except Exception: + out_dir = Path("output") + out_dir.mkdir(parents=True, exist_ok=True) + slug = model_id.replace("/", "_") + path = out_dir / f"replicate_{slug}_{int(time.time())}.mp4" + path.write_bytes(data) + return str(path) + + +def _first_output(result): + """Replicate gibt je nach Modell FileOutput, Liste oder dict zurück.""" + if isinstance(result, (list, tuple)): + return result[0] if result else None + if isinstance(result, dict): + for key in ("video", "audio", "image", "output", "url"): + if key in result: + return result[key] + return next(iter(result.values()), None) + return result + + +# ---------- Node-Generierung ---------- + +def _comfy_input(entry: dict): + kind = entry["kind"] + tooltip = {"tooltip": entry["description"]} if entry.get("description") else {} + if kind == "combo": + return ([str(o) for o in entry["options"]], {"default": str(entry.get("default", "")), **tooltip}) + if kind == "bool": + return ("BOOLEAN", {"default": entry.get("default", False), **tooltip}) + if kind == "int": + return ("INT", {"default": int(entry.get("default") or 0), + "min": int(max(entry.get("min", -2**31), -2**31)), + "max": int(min(entry.get("max", 2**31 - 1), 2**31 - 1)), **tooltip}) + if kind == "float": + return ("FLOAT", {"default": float(entry.get("default") or 0.0), + "min": float(entry.get("min", -1e9)), "max": float(entry.get("max", 1e9)), + "step": 0.01, **tooltip}) + if kind == "image": + return ("IMAGE", tooltip) + if kind == "audio": + return ("AUDIO", tooltip) + # string / uri_string + return ("STRING", {"default": str(entry.get("default") or ""), + "multiline": entry.get("multiline", False), **tooltip}) + + +def _build_arguments(model_def: dict, kwargs: dict) -> dict: + args = {} + for entry in model_def["inputs"]: + name = entry["name"] + value = kwargs.get(name) + if value is None: + continue + kind = entry["kind"] + if kind == "image": + uri = _image_to_uri(value) + args[name] = [uri] if entry.get("is_list") else uri + elif kind == "audio": + args[name] = _audio_to_uri(value) + elif kind == "combo": + args[name] = value + elif kind in ("string", "uri_string"): + if str(value).strip(): + args[name] = value + else: + args[name] = value + return args + + +_RETURN_BY_OUTPUT = { + "image": (("IMAGE",), ("image",)), + "audio": (("AUDIO",), ("audio",)), + "video": (("STRING",), ("video_path",)), +} + + +def _make_node(model_def: dict): + output_kind = model_def["output"] + return_types, return_names = _RETURN_BY_OUTPUT[output_kind] + + required, optional = {}, {} + for entry in model_def["inputs"]: + target = required if entry["required"] else optional + target[entry["name"]] = _comfy_input(entry) + + class ReplicateModelNode: + CATEGORY = f"{CATEGORY_ROOT}/{model_def['category']}" + FUNCTION = "run" + RETURN_TYPES = return_types + RETURN_NAMES = return_names + + @classmethod + def INPUT_TYPES(cls): + spec = {"required": dict(required)} + if optional: + spec["optional"] = dict(optional) + return spec + + def run(self, **kwargs): + replicate = _client() + args = _build_arguments(model_def, kwargs) + print(f"[Replicate Select] {model_def['id']} ← {list(args.keys())}") + result = replicate.run(model_def["id"], input=args) + output = _first_output(result) + if output is None: + raise RuntimeError(f"{model_def['id']}: leere Antwort") + data = _download(output) + if output_kind == "image": + return (_to_image_tensor(data),) + if output_kind == "audio": + return (_to_audio(data),) + return (_save_video(data, model_def["id"]),) + + ReplicateModelNode.__name__ = "Replicate_" + model_def["id"].replace("/", "_").replace("-", "_").replace(".", "_") + return ReplicateModelNode + + +class ReplicateCustom: + """Beliebiges Replicate-Modell per ID aufrufen — der Escape-Hatch.""" + + CATEGORY = CATEGORY_ROOT + FUNCTION = "run" + RETURN_TYPES = ("STRING", "IMAGE") + RETURN_NAMES = ("raw_output", "image") + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model_id": ("STRING", {"default": "owner/model", "tooltip": "z.B. bytedance/seedream-4.5"}), + "input_json": ("STRING", {"multiline": True, "default": '{\n "prompt": ""\n}', + "tooltip": "Input-Parameter als JSON"}), + }, + "optional": { + "image": ("IMAGE", {"tooltip": "Wird als 'image'-Parameter angehängt, falls gesetzt"}), + }, + } + + def run(self, model_id, input_json, image=None): + replicate = _client() + args = json.loads(input_json) + if image is not None: + args["image"] = _image_to_uri(image) + result = replicate.run(model_id.strip(), input=args) + output = _first_output(result) + raw = str(output) + image_out = torch.zeros(1, 1, 1, 3) + try: + data = _download(output) + if data[:8].startswith((b"\x89PNG", b"\xff\xd8")): + image_out = _to_image_tensor(data) + else: + raw = f"{raw}\n(bytes: {len(data)})" + except Exception: + pass + return (raw, image_out) + + +def _load_catalog() -> dict: + with open(CATALOG_PATH, encoding="utf-8") as fh: + return json.load(fh) + + +NODE_CLASS_MAPPINGS = {"ReplicateCustom": ReplicateCustom} +NODE_DISPLAY_NAME_MAPPINGS = {"ReplicateCustom": "Replicate Custom (beliebige Modell-ID)"} + +for _model in _load_catalog()["models"]: + _cls = _make_node(_model) + NODE_CLASS_MAPPINGS[_cls.__name__] = _cls + NODE_DISPLAY_NAME_MAPPINGS[_cls.__name__] = f"{_model['name']} (Replicate)" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8e7a262 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "comfyui-replicate-select" +description = "Kuratierte Replicate-Modelle als ComfyUI-Nodes (Bild, Video, Upscale, TTS, Lipsync) + Custom-Node für beliebige Modell-IDs." +version = "0.1.0" +dependencies = ["replicate>=1.0.7"] + +[tool.comfy] +PublisherId = "pharma-lobby" +DisplayName = "Replicate Select" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..849fe83 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +replicate>=1.0.7 diff --git a/scripts/build_catalog.py b/scripts/build_catalog.py new file mode 100644 index 0000000..0268647 --- /dev/null +++ b/scripts/build_catalog.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Baut nodes.json aus den öffentlichen Replicate-Schema-Seiten (kein API-Token nötig). + +Aufruf: python scripts/build_catalog.py +Schreibt: nodes.json im Repo-Root. + +Neue Modelle: unten in META eintragen, Script laufen lassen, fertig. +""" +import json +import re +import time +import urllib.request +from pathlib import Path + +# Modell-ID → (Anzeigename, Kategorie, Output-Art: image|video|audio) +META = { + "bytedance/seedream-4.5": ("Seedream 4.5", "image", "image"), + "google/nano-banana-pro": ("Nano Banana Pro", "image", "image"), + "kwaivgi/kling-v3-video": ("Kling v3 Video", "video", "video"), + "google/veo-3.1": ("Veo 3.1", "video", "video"), + "bytedance/seedance-2.0-fast": ("Seedance 2.0 Fast", "video", "video"), + "cjwbw/supir": ("SUPIR Upscale", "upscale", "image"), + "cjwbw/real-esrgan": ("Real-ESRGAN Upscale", "upscale", "image"), + "minimax/speech-2.8-hd": ("MiniMax Speech 2.8 HD","tts", "audio"), + "inworld/realtime-tts-2": ("Inworld TTS 2", "tts", "audio"), + "elevenlabs/v3": ("ElevenLabs v3", "tts", "audio"), + "veed/fabric-1.0": ("VEED Fabric 1.0", "lipsync", "video"), + "prunaai/p-video-avatar": ("Pruna Video Avatar", "lipsync", "video"), + "sync/lipsync-2-pro": ("Sync Lipsync 2 Pro", "lipsync", "video"), + "heygen/lipsync-precision": ("HeyGen Lipsync Precision", "lipsync", "video"), +} + + +def fetch_schema(model_id: str) -> dict | None: + """Schema aus dem eingebetteten JSON der öffentlichen Schema-Seite ziehen.""" + req = urllib.request.Request( + f"https://replicate.com/{model_id}/api/schema", + headers={"User-Agent": "Mozilla/5.0"}, + ) + html = urllib.request.urlopen(req, timeout=30).read().decode() + for m in re.finditer(r']*type="application/json"[^>]*>(.*?)', html, re.DOTALL): + try: + data = json.loads(m.group(1)) + except Exception: + continue + version = data.get("version") or data + schema = version.get("_extras", {}).get("dereferenced_openapi_schema") or {} + schemas = schema.get("components", {}).get("schemas", {}) + if schemas.get("Input"): + return {"input": schemas["Input"], "version_id": version.get("id")} + return None + + +def classify_uri(name: str, is_list: bool) -> dict: + """URI-Inputs nach Medientyp einsortieren.""" + lname = name.lower() + if any(k in lname for k in ("audio", "voice_sample", "speech")): + return {"kind": "audio"} + if any(k in lname for k in ("video", "motion")): + return {"kind": "uri_string"} # Video-Inputs: URL als String (zu groß für Upload-Tricks) + if any(k in lname for k in ("image", "img", "frame", "mask", "reference")): + return {"kind": "image", "is_list": is_list} + return {"kind": "uri_string"} + + +def distill(model_id: str, input_schema: dict) -> dict: + props = input_schema.get("properties", {}) + required = set(input_schema.get("required", [])) + inputs = [] + for name, p in props.items(): + if "allOf" in p and p["allOf"]: + merged = dict(p["allOf"][0]) + merged.update({k: v for k, v in p.items() if k != "allOf"}) + p = merged + entry = { + "name": name, + "order": p.get("x-order", 999), + "required": name in required, + "description": (p.get("description") or "")[:200], + } + t = p.get("type") + fmt = p.get("format", "") + items = p.get("items", {}) if isinstance(p.get("items"), dict) else {} + is_uri = fmt == "uri" or items.get("format") == "uri" + is_list = t == "array" + lname = name.lower() + + if p.get("enum"): + entry["kind"] = "combo" + entry["options"] = p["enum"] + entry["default"] = p.get("default", p["enum"][0]) + elif t == "boolean": + entry["kind"] = "bool" + entry["default"] = bool(p.get("default", False)) + elif t == "integer": + entry["kind"] = "int" + entry["default"] = p.get("default", 0) + entry["min"] = p.get("minimum", -2**31) + entry["max"] = p.get("maximum", 2**31 - 1) + elif t == "number": + entry["kind"] = "float" + entry["default"] = p.get("default", 0.0) + entry["min"] = p.get("minimum", -1e9) + entry["max"] = p.get("maximum", 1e9) + elif is_uri: + entry.update(classify_uri(name, is_list)) + else: + entry["kind"] = "string" + entry["default"] = p.get("default") or "" + if any(k in lname for k in ("prompt", "text", "instruction", "script")): + entry["multiline"] = True + inputs.append(entry) + inputs.sort(key=lambda e: (not e["required"], e["order"])) + name, cat, out = META[model_id] + return {"id": model_id, "name": name, "category": cat, "output": out, "inputs": inputs} + + +def main(): + models = [] + for model_id in META: + try: + fetched = fetch_schema(model_id) + except Exception as exc: + print(f"✗ {model_id}: {exc}") + continue + if not fetched: + print(f"✗ {model_id}: kein Schema gefunden") + continue + entry = distill(model_id, fetched["input"]) + models.append(entry) + print(f"✓ {model_id}: {len(entry['inputs'])} inputs") + time.sleep(1) + + out_path = Path(__file__).resolve().parent.parent / "nodes.json" + out_path.write_text(json.dumps({"models": models}, indent=1, ensure_ascii=False)) + print(f"→ {out_path} ({len(models)}/{len(META)} Modelle)") + + +if __name__ == "__main__": + main()