diff --git a/README.md b/README.md index c18ec9d8..e4f8cc1d 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,64 @@ async def main() -> None: - `cacheStopStep`: The step at which caching ends (default: total steps) - These parameters allow fine-grained control over when caching is active during the generation process. +### Asynchronous Processing with Webhooks + +The Runware SDK supports asynchronous processing via webhooks for long-running operations. When you provide a `webhookURL`, the API immediately returns a task response and sends the final result to your webhook endpoint when processing completes. + +#### How it works + +1. Include `webhookURL` parameter in your request +2. Receive immediate response with `taskType` and `taskUUID` +3. Final result is POSTed to your webhook URL when ready + +Supported operations: +- Image Inference +- Photo Maker +- Image Caption +- Image Background Removal +- Image Upscale +- Prompt Enhance +- Video Inference + +#### Example + +```python +from runware import Runware, IImageInference + +async def main() -> None: + runware = Runware(api_key=RUNWARE_API_KEY) + await runware.connect() + + request_image = IImageInference( + positivePrompt="a beautiful mountain landscape", + model="civitai:36520@76907", + height=512, + width=512, + webhookURL="https://your-server.com/webhook/runware" + ) + + # Returns immediately with task info + response = await runware.imageInference(requestImage=request_image) + print(f"Task Type: {response.taskType}") + print(f"Task UUID: {response.taskUUID}") + # Result will be sent to your webhook URL +``` + +#### Webhook Payload Format +Your webhook endpoint will receive a POST request with the same format as synchronous responses: +```json{ + "data": [ + { + "taskType": "imageInference", + "taskUUID": "a770f077-f413-47de-9dac-be0b26a35da6", + "imageUUID": "77da2d99-a6d3-44d9-b8c0-ae9fb06b6200", + "imageURL": "https://im.runware.ai/image/...", + "cost": 0.0013 + } + ] +} +``` + ### Enhancing Prompts To enhance prompts using the Runware API, you can use the `promptEnhance` method of the `Runware` class. Here's an example: diff --git a/runware/__init__.py b/runware/__init__.py index ca41818f..5867828e 100644 --- a/runware/__init__.py +++ b/runware/__init__.py @@ -6,4 +6,4 @@ from .async_retry import * __all__ = ["Runware"] -__version__ = "0.4.24" +__version__ = "0.4.25" diff --git a/runware/base.py b/runware/base.py index 4cee9e87..884dac39 100644 --- a/runware/base.py +++ b/runware/base.py @@ -39,6 +39,7 @@ IGoogleProviderSettings, IKlingAIProviderSettings, IFrameImage, + IAsyncTaskResponse, ) from .types import IImage, IError, SdkType, ListenerType from .utils import ( @@ -59,6 +60,7 @@ LISTEN_TO_IMAGES_KEY, isLocalFile, process_image, delay, + createAsyncTaskResponse, ) # Configure logging @@ -199,6 +201,8 @@ async def photoMaker(self, requestPhotoMaker: IPhotoMaker): request_object["includeCost"] = requestPhotoMaker.includeCost if requestPhotoMaker.outputType: request_object["outputType"] = requestPhotoMaker.outputType + if requestPhotoMaker.webhookURL: + request_object["webhookURL"] = requestPhotoMaker.webhookURL await self.send([request_object]) @@ -466,8 +470,9 @@ async def _requestImageToText( # Add optional parameters if they are provided if requestImageToText.includeCost: task_params["includeCost"] = requestImageToText.includeCost + if requestImageToText.webhookURL: + task_params["webhookURL"] = requestImageToText.webhookURL - await self.send([task_params]) lis = self.globalListener( @@ -552,6 +557,8 @@ async def _removeImageBackground( task_params["model"] = removeImageBackgroundPayload.model if removeImageBackgroundPayload.outputQuality: task_params["outputQuality"] = removeImageBackgroundPayload.outputQuality + if removeImageBackgroundPayload.webhookURL: + task_params["webhookURL"] = removeImageBackgroundPayload.webhookURL # Handle settings if provided - convert dataclass to dictionary and add non-None values if removeImageBackgroundPayload.settings: @@ -635,6 +642,8 @@ async def _upscaleGan(self, upscaleGanPayload: IImageUpscale) -> List[IImage]: task_params["outputFormat"] = upscaleGanPayload.outputFormat if upscaleGanPayload.includeCost: task_params["includeCost"] = upscaleGanPayload.includeCost + if upscaleGanPayload.webhookURL: + task_params["webhookURL"] = upscaleGanPayload.webhookURL # Send the task with all applicable parameters await self.send([task_params]) @@ -721,6 +730,10 @@ async def _enhancePrompt( if promptEnhancer.includeCost: task_params["includeCost"] = promptEnhancer.includeCost + has_webhook = promptEnhancer.webhookURL + if has_webhook: + task_params["webhookURL"] = promptEnhancer.webhookURL + # Send the task with all applicable parameters await self.send([task_params]) @@ -1284,12 +1297,16 @@ async def videoInference(self, requestVideo: IVideoInference) -> List[IVideo]: await self.ensureConnection() return await asyncRetry(lambda: self._requestVideo(requestVideo)) - async def _requestVideo(self, requestVideo: IVideoInference) -> List[IVideo]: + async def _requestVideo(self, requestVideo: IVideoInference) -> Union[List[IVideo], IAsyncTaskResponse]: await self._processVideoImages(requestVideo) requestVideo.taskUUID = requestVideo.taskUUID or getUUID() request_object = self._buildVideoRequest(requestVideo) + + if requestVideo.webhookURL: + request_object["webhookURL"] = requestVideo.webhookURL + await self.send([request_object]) - return await self._handleInitialVideoResponse(requestVideo.taskUUID, requestVideo.numberResults) + return await self._handleInitialVideoResponse(requestVideo.taskUUID, requestVideo.numberResults, request_object.get("webhookURL")) async def _processVideoImages(self, requestVideo: IVideoInference) -> None: frame_tasks = [] @@ -1391,7 +1408,7 @@ def _addOptionalImageFields(self, request_object: Dict[str, Any], requestImage: "outputType", "outputFormat", "outputQuality", "uploadEndpoint", "includeCost", "checkNsfw", "negativePrompt", "seedImage", "maskImage", "strength", "height", "width", "steps", "scheduler", "seed", "CFGScale", - "clipSkip", "promptWeighting", "maskMargin", "vae" + "clipSkip", "promptWeighting", "maskMargin", "vae", "webhookURL" ] for field in optional_fields: @@ -1499,7 +1516,7 @@ def _addProviderSettings(self, request_object: Dict[str, Any], requestVideo: IVi if provider_dict: request_object["providerSettings"] = provider_dict - async def _handleInitialVideoResponse(self, task_uuid: str, number_results: int) -> List[IVideo]: + async def _handleInitialVideoResponse(self, task_uuid: str, number_results: int, webhook_url: Optional[str] = None) -> Union[List[IVideo], IAsyncTaskResponse]: lis = self.globalListener(taskUUID=task_uuid) def check_initial_response(resolve: callable, reject: callable, *args: Any) -> bool: @@ -1518,6 +1535,14 @@ def check_initial_response(resolve: callable, reject: callable, *args: Any) -> b resolve([response]) return True + # Check if this is a webhook response (no imageUUID means async task accepted) + if not response.get("imageUUID") and webhook_url: + del self._globalMessages[task_uuid] + # Return async task response for webhook + async_response = createAsyncTaskResponse(response) + resolve([async_response]) + return True + del self._globalMessages[task_uuid] resolve("POLL_NEEDED") return True diff --git a/runware/types.py b/runware/types.py index 8eb48ff3..b2ce07d0 100644 --- a/runware/types.py +++ b/runware/types.py @@ -108,6 +108,12 @@ class File: data: bytes +@dataclass +class IAsyncTaskResponse: + taskType: str + taskUUID: str + + @dataclass class RunwareBaseType: apiKey: str @@ -304,6 +310,7 @@ class IPhotoMaker: outputFormat: Optional[IOutputFormat] = None includeCost: Optional[bool] = None taskUUID: Optional[str] = None + webhookURL: Optional[str] = None def __post_init__(self): # Validate `inputImages` to ensure it has a maximum of 4 elements @@ -484,6 +491,7 @@ class IImageCaption: model: Optional[str] = None # Optional: AIR ID (runware:150@1, runware:150@2) - backend handles default includeCost: bool = False template: Optional[str] = None + webhookURL: Optional[str] = None @dataclass @@ -548,6 +556,7 @@ class IPromptEnhance: promptVersions: int prompt: str includeCost: bool = False + webhookURL: Optional[str] = None @dataclass @@ -565,6 +574,7 @@ class IImageUpscale: outputType: Optional[IOutputType] = None outputFormat: Optional[IOutputFormat] = None includeCost: bool = False + webhookURL: Optional[str] = None class ReconnectingWebsocketProps: @@ -811,6 +821,7 @@ class IVideoInference: numberResults: Optional[int] = 1 providerSettings: Optional[VideoProviderSettings] = None speech: Optional[IPixverseSpeechSettings] = None + webhookURL: Optional[str] = None @dataclass diff --git a/runware/utils.py b/runware/utils.py index 487c0918..8fed962d 100644 --- a/runware/utils.py +++ b/runware/utils.py @@ -27,6 +27,7 @@ IEnhancedPrompt, IError, UploadImageType, + IAsyncTaskResponse, ) import logging @@ -585,6 +586,16 @@ def process_single_prompt(prompt_data: dict) -> IEnhancedPrompt: return [process_single_prompt(prompt) for prompt in response] +def createAsyncTaskResponse(response: dict) -> IAsyncTaskResponse: + processed_fields = {} + + for field in fields(IAsyncTaskResponse): + if field.name in response: + processed_fields[field.name] = response[field.name] + + return instantiateDataclass(IAsyncTaskResponse, processed_fields) + + def createImageFromResponse(response: dict) -> IImage: processed_fields = {} diff --git a/setup.py b/setup.py index 3b5eff23..3a0bdec7 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="runware", license="MIT", - version="0.4.24", + version="0.4.25", author="Runware Inc.", author_email="python.sdk@runware.ai", description="The Python Runware SDK is used to run image inference with the Runware API, powered by the Runware inference platform. It can be used to generate images with text-to-image and image-to-image. It also allows the use of an existing gallery of models or selecting any model or LoRA from the CivitAI gallery. The API also supports upscaling, background removal, inpainting and outpainting, and a series of other ControlNet models.",