From 263cb060eeaf8782aeaf6c33ffb67f042d34f70a Mon Sep 17 00:00:00 2001 From: Pharma-Lobby Date: Fri, 10 Jul 2026 16:06:22 +0000 Subject: [PATCH] =?UTF-8?q?ComfyUI-Replicate-Select=20v0.1.0=20=E2=80=94?= =?UTF-8?q?=2014=20kuratierte=20Modelle=20+=20ReplicateCustom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 43 + __init__.py | 3 + __pycache__/nodes.cpython-311.pyc | Bin 0 -> 16098 bytes nodes.json | 1486 +++++++++++++++++++++++++++++ nodes.py | 262 +++++ pyproject.toml | 9 + requirements.txt | 1 + scripts/build_catalog.py | 140 +++ 8 files changed, 1944 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __pycache__/nodes.cpython-311.pyc create mode 100644 nodes.json create mode 100644 nodes.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 scripts/build_catalog.py 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 0000000000000000000000000000000000000000..d957b89b677e16fe360329ef6084bb1c142b3b55 GIT binary patch literal 16098 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 z zZ?hen7iIR4!X5&Y6h=TSTk9li-40W2f+75Okx#JNXW6<(spf95%6os=@U&-St7qgR z*GIE*&!o~b`Lt(dt7k@f<-5}FK@O+%a8G;uTRnbhaY+)wa?f?8=epc<9o`9Iw~~%J ziK!!Vro%q4ypiM67vPjdJ{&z`~cZu&D z!7#rrO{@$8B5w_z8(^!=dn4`QNARs%^UaW9gx{yVK(wGUj39o1V2tKWxRJ?OA0TM6 zJurX++QAiYg+6di`@khkwxpfo1%eDZCZkGfhDo2k8NtQudY;%VjyDY1+M^V3HCBbfbh4cCdR>h zcoS-A?k21cZM_U9*cZVX9{k^dwkV-#Li|f%e6g2(0!JEYCtE7*xlIS=`GsG<6POn} z*g3Bs{`3H&&xzIwld34B0M`8poU|BrB1J@#t<+?uN}I`Cbf#j3D?nhY&`Q;} zJvu3!zbds~!*8uW+0gz-lt#XrJUjtzjD0Vn14E+?lk|>>sVV{;1f+l}sf@QLMx}0E zYMzs-=68$+$S$-MLpZxeY<5kZ&!#atJd?^y?&l!bUkm4$TxUe~e+&6BE8x~J{-9J3 z-SmJZ0)G=a0gr~D*W8|9Z^-dgJLY<6iI_gf+izPn>mXVl^jWp!!m~)8cYf8H3&ygc z?plZ#$LOOOS8X;*2ea~9vq$V4K)&D4u_|{D4F)R^#yz`BAQbDug6;adtF}OS#0p^2 zA|eP+9g)0JaNqhivuX_#L!3ixx)eo0oP!aA*>WNotPvaf_rbZMiNv2l>5rj9)W<0B zAEUADHV|uM4dQ@_+xyGpip{>4M6maH-kew89I)5P^8A9d;@OF?7QxE@*HM%`m|W#O)CN9MdSg!hb_T+ycn3dAJYkIqwZs zi$_K+MsQ#Vcl}bR4^NsjBBWSxjoG%HuJx7pKH1r- zI9tIz<0zIKwcEAs%~rYgfKqz^yd@QD=V5`nU-xER{N_(y|MBZuko_ZZqfRb4p_H6} zg{`DMeqAnUQ%c(4q6qd&*RHK!iy61es_vaj(21Uece6w;+pm=EkJ)w%7F$UkoO5jq zZoIfLxPJ4gvuVrOBsHIqohKFNN%)sa;oMR4|YKx8~UHhVA&rHQT2D=IL8xTNU z=91g5Xc?6yCKjD*yAtiW_(@<~#TDEaJ`Qz=op?=z}YN`I47L6m%+IbAVvV`T01_16_=OClmW4@C=- z#ZB;#lA37Ij@?3+?@$?1W}&O%Cd{bXbdl5JA#!@6ok%)|R9fgEIsscuc;1DUnIS(8 zQB`ccG8$S#1C&THHNKX0W09DkM zENo2{?%%O6=d=p_3@S+3QqZCl5V3xcRo)(!MxA1HzsKVUAF%sVfQq+#abphTFY4l9eQ6B5tn3ElK xilYv8OWd6jQxRt#6v|9n^3buzZNEGv9hgXc8TfD9gH@S 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()