commit 83d3ccdc7eab9c5a1b979eb4db2fe200e953c0fb Author: henryruhs Date: Mon Jun 30 18:12:45 2025 +0200 Initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b88a39d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_size = 4 +indent_style = tab +trim_trailing_whitespace = true diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..c20e2a7 --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +select = E22, E23, E24, E27, E3, E4, E7, F, I1, I2 +plugins = flake8-import-order +application_import_names = facefusion +import-order-style = pycharm diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..cce56bd --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: [ buymeacoffee.com/facefusion, ko-fi.com/facefusion ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..12eb4e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: ci + +on: [ push, pull_request ] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install flake8 + - run: pip install flake8-import-order + - run: pip install mypy + - run: flake8 facefusion_api + - run: mypy facefusion_api diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ee9a7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +.idea +.vscode diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..32361dc --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,3 @@ +OpenRAIL-S license + +Copyright (c) 2025 Henry Ruhs diff --git a/README.md b/README.md new file mode 100644 index 0000000..7050275 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +FaceFusion ComfyUI +================== + +> Industry leading face manipulation platform. + +[![Build Status](https://img.shields.io/github/actions/workflow/status/facefusion/facefusion-comfyui/ci.yml.svg?branch=master)](https://github.com/facefusion/facefusion-comfyui/actions?query=workflow:ci) +![License](https://img.shields.io/badge/license-OpenRAIL--S-green) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..86744cb --- /dev/null +++ b/__init__.py @@ -0,0 +1,10 @@ +from .facefusion_api import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +from install import install + +install() + +__all__ =\ +[ + 'NODE_CLASS_MAPPINGS', + 'NODE_DISPLAY_NAME_MAPPINGS' +] diff --git a/facefusion_api/__init__.py b/facefusion_api/__init__.py new file mode 100644 index 0000000..c7a755d --- /dev/null +++ b/facefusion_api/__init__.py @@ -0,0 +1,14 @@ +from .core import SwapFaceImage, SwapFaceVideo +from .types import NodeClassMapping, NodeDisplayNameMapping + +NODE_CLASS_MAPPINGS : NodeClassMapping =\ +{ + 'SwapFaceImage': SwapFaceImage, + 'SwapFaceVideo': SwapFaceVideo +} + +NODE_DISPLAY_NAME_MAPPINGS : NodeDisplayNameMapping =\ +{ + 'SwapFaceImage': 'Image Swap Face', + 'SwapFaceVideo': 'Video Swap Face' +} diff --git a/facefusion_api/core.py b/facefusion_api/core.py new file mode 100644 index 0000000..c9ad542 --- /dev/null +++ b/facefusion_api/core.py @@ -0,0 +1,151 @@ +from concurrent.futures import ThreadPoolExecutor +from functools import partial +from io import BytesIO +from typing import Tuple + +import torch +from comfy.comfy_types import IO +from comfy_api.input_impl.video_types import VideoFromComponents +from comfy_api.util import VideoComponents +from comfy_api_nodes.apinode_utils import bytesio_to_image_tensor, tensor_to_bytesio +from httpx import Client as HttpClient, Headers +from torch import Tensor + +from .types import FaceSwapperModel, NodeInputTypes + + +class SwapFaceImage: + @classmethod + def INPUT_TYPES(cls) -> NodeInputTypes: + return\ + { + 'required': + { + 'source_image': (IO.IMAGE,), + 'target_image': (IO.IMAGE,), + 'api_token': + ( + 'STRING', + { + 'default': '0' + } + ), + 'face_swapper_model': + ( + [ + 'hyperswap_1a_256', + 'hyperswap_1b_256', + 'hyperswap_1c_256' + ], + { + 'default': 'hyperswap_1c_256' + } + ) + } + } + + RETURN_TYPES = (IO.IMAGE,) + FUNCTION = 'process' + CATEGORY = 'FaceFusion API' + + @staticmethod + def process(source_image : Tensor, target_image : Tensor, api_token : str, face_swapper_model : FaceSwapperModel) -> Tuple[Tensor]: + output_tensor: Tensor = SwapFaceImage.swap_face(source_image, target_image, api_token, face_swapper_model) + return (output_tensor,) + + @staticmethod + def swap_face(source_tensor : Tensor, target_tensor, api_token : str, face_swapper_model : FaceSwapperModel) -> Tensor: + source_buffer : BytesIO = tensor_to_bytesio(source_tensor, mime_type = 'image/webp') + target_buffer : BytesIO = tensor_to_bytesio(target_tensor, mime_type = 'image/webp') + + url = 'https://api.facefusion.io/inferences/swap-face' + files =\ + { + 'source': ('source.webp', source_buffer, 'image/webp'), + 'target': ('target.webp', target_buffer, 'image/webp'), + } + data =\ + { + 'face_swapper_model': face_swapper_model, + } + headers = Headers() + + if api_token: + headers['X-Token'] = api_token + + with HttpClient(timeout = 10) as http_client: + response = http_client.post(url, headers = headers, files = files, data = data) + + output_buffer = BytesIO(response.content) + output_tensor = bytesio_to_image_tensor(output_buffer) + return output_tensor + + +class SwapFaceVideo: + @classmethod + def INPUT_TYPES(cls) -> NodeInputTypes: + return\ + { + 'required': + { + 'source_image': (IO.IMAGE,), + 'target_video': (IO.VIDEO,), + 'api_token': + ( + 'STRING', + { + 'default': '0' + } + ), + 'face_swapper_model': + ( + [ + 'hyperswap_1a_256', + 'hyperswap_1b_256', + 'hyperswap_1c_256' + ], + { + 'default': 'hyperswap_1a_256' + } + ), + 'max_workers': + ( + 'INT', + { + 'default': 16, + 'min': 1, + 'max': 32 + } + ) + } + } + + RETURN_TYPES = (IO.VIDEO,) + FUNCTION = 'process' + CATEGORY = 'FaceFusion API' + + @staticmethod + def process(source_image : Tensor, target_video : VideoFromComponents, api_token : str, face_swapper_model : FaceSwapperModel, max_workers : int) -> Tuple[VideoFromComponents]: + video_components = target_video.get_components() + output_tensors = [] + + swap_face = partial( + SwapFaceImage.swap_face, + source_image, + api_token = api_token, + face_swapper_model = face_swapper_model + ) + + with ThreadPoolExecutor(max_workers = max_workers) as executor: + for temp_tensor in executor.map(swap_face, video_components.images): + temp_tensor = temp_tensor.squeeze(0)[..., :3] + output_tensors.append(temp_tensor) + + output_video_components = VideoComponents( + images = torch.stack(output_tensors), + audio = video_components.audio, + frame_rate = video_components.frame_rate + ) + + output_video = VideoFromComponents(output_video_components) + return (output_video,) diff --git a/facefusion_api/types.py b/facefusion_api/types.py new file mode 100644 index 0000000..342ebea --- /dev/null +++ b/facefusion_api/types.py @@ -0,0 +1,9 @@ +from typing import Any, Dict, Literal, Type, TypeAlias + +NodeInputTypes : TypeAlias = Dict[str, Any] +NodeClassMapping : TypeAlias = Dict[str, Type] +NodeDisplayNameMapping : TypeAlias = Dict[str, str] + +Image : TypeAlias = Any + +FaceSwapperModel = Literal['hyperswap_1a_256', 'hyperswap_1b_256', 'hyperswap_1c_256'] diff --git a/install.py b/install.py new file mode 100644 index 0000000..bf79ec6 --- /dev/null +++ b/install.py @@ -0,0 +1,6 @@ +import subprocess +from shutil import which + + +def install() -> None: + subprocess.run([ which('pip'), 'install', '-r', 'requirements.txt' ]) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..64218bc --- /dev/null +++ b/mypy.ini @@ -0,0 +1,7 @@ +[mypy] +check_untyped_defs = True +disallow_any_generics = True +disallow_untyped_calls = True +disallow_untyped_defs = True +ignore_missing_imports = True +strict_optional = False diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..716d1ee --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +httpx==0.28.1 +