From 1af5f4eb341f4952b218d9e0fa917b5e49431e9b Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Mon, 19 Aug 2024 18:17:36 +0300 Subject: [PATCH] dev: preparing first release Signed-off-by: Alexander Piskun --- .github/workflows/publish-docker-cuda.yml | 2 +- .github/workflows/publish-docker-rocm.yml | 2 +- .gitignore | 11 +- CHANGELOG.md | 9 + Dockerfile | 4 +- Makefile | 10 +- appinfo/info.xml | 21 ++- ex_app/lib/main.py | 220 +++++++++++++++++++++- ex_app/lib/supported_flows.py | 13 ++ pyproject.toml | 2 +- requirements.txt | 2 +- 11 files changed, 269 insertions(+), 27 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 ex_app/lib/supported_flows.py diff --git a/.github/workflows/publish-docker-cuda.yml b/.github/workflows/publish-docker-cuda.yml index 1e3e909..c763c9e 100644 --- a/.github/workflows/publish-docker-cuda.yml +++ b/.github/workflows/publish-docker-cuda.yml @@ -45,6 +45,6 @@ jobs: context: . platforms: linux/amd64 file: Dockerfile - tags: ghcr.io/cloud-py-api/visionatrix-cuda:${{ env.VERSION }} + tags: ghcr.io/cloud-py-api/visionatrix:${{ env.VERSION }}-cuda build-args: | BUILD_TYPE=cuda diff --git a/.github/workflows/publish-docker-rocm.yml b/.github/workflows/publish-docker-rocm.yml index 0bc9b9d..8ed4da1 100644 --- a/.github/workflows/publish-docker-rocm.yml +++ b/.github/workflows/publish-docker-rocm.yml @@ -45,6 +45,6 @@ jobs: context: . platforms: linux/amd64 file: Dockerfile - tags: ghcr.io/cloud-py-api/visionatrix-rocm:${{ env.VERSION }} + tags: ghcr.io/cloud-py-api/visionatrix:${{ env.VERSION }}-rocm build-args: | BUILD_TYPE=cuda diff --git a/.gitignore b/.gitignore index 6543803..b49db7f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,12 +20,19 @@ coverage/ vendor .php-cs-fixer.cache .phpunit.result.cache + +# Environments .env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + __pycache__ local .run/ -.venv -venv dev Visionatrix diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..01fed2e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.0.0 - 2024-08-xx] + +### Added + +- First release. diff --git a/Dockerfile b/Dockerfile index 5d0227c..2fd5111 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ ARG BUILD_TYPE # Visionatrix enviroment variables ENV VIX_HOST="127.0.0.1" ENV VIX_PORT=8288 -ENV USER_BACKENDS="nextcloud" +ENV USER_BACKENDS="vix_db;nextcloud" ENV FLOWS_DIR="/nc_app_vix_data/vix_flows" ENV MODELS_DIR="/nc_app_vix_data/vix_models" ENV TASKS_FILES_DIR="/nc_app_vix_data/vix_tasks_files" @@ -33,7 +33,7 @@ RUN cd /Visionatrix && \ echo "Installing PyTorch for ARM64"; \ venv/bin/python -m pip install torch torchvision torchaudio; \ elif [ "$BUILD_TYPE" = "rocm" ]; then \ - venv/bin/python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.0; \ + venv/bin/python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.1; \ elif [ "$BUILD_TYPE" = "cpu" ]; then \ venv/bin/python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu; \ else \ diff --git a/Makefile b/Makefile index 4d87f48..8a1d6f4 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,12 @@ build-push-rocm: docker login ghcr.io docker buildx build --push --platform linux/amd64 --tag ghcr.io/cloud-py-api/visionatrix-rocm:$$(xmlstarlet sel -t -v "//image-tag" appinfo/info.xml) --build-arg BUILD_TYPE=rocm . +.PHONY: run30 +run30: + docker exec master-stable30-1 sudo -u www-data php occ app_api:app:unregister visionatrix --silent --force || true + docker exec master-stable30-1 sudo -u www-data php occ app_api:app:register visionatrix --force-scopes \ + --info-xml https://raw.githubusercontent.com/cloud-py-api/visionatrix/main/appinfo/info.xml + .PHONY: run run: docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:unregister visionatrix --silent --force || true @@ -46,9 +52,9 @@ run: .PHONY: register register: docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:unregister visionatrix --silent --force || true - docker exec master-nextcloud-1 rm -rf /tmp/vix_l10n && docker cp l10n master-nextcloud-1:/tmp/vix_l10n + docker exec master-nextcloud-1 rm -rf /tmp/vix_l10n && docker cp ex_app/l10n master-nextcloud-1:/tmp/vix_l10n docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:register visionatrix manual_install --json-info \ - "{\"id\":\"visionatrix\",\"name\":\"Visionatrix\",\"daemon_config_name\":\"manual_install\",\"version\":\"1.0.0\",\"secret\":\"12345\",\"port\":9100,\"scopes\":[\"AI_PROVIDERS\", \"FILES\", \"USER_INFO\"],\"system_app\":0, \"translations_folder\":\"\/tmp\/vix_l10n\"}" \ + "{\"id\":\"visionatrix\",\"name\":\"Visionatrix\",\"daemon_config_name\":\"manual_install\",\"version\":\"1.0.0\",\"secret\":\"12345\",\"port\":9100,\"scopes\":[\"AI_PROVIDERS\", \"FILES\", \"USER_INFO\"], \"routes\": [{\"url\":\".*\",\"verb\":\"GET, POST, PUT, DELETE\",\"access_level\":1,\"headers_to_exclude\":[]}], \"translations_folder\":\"\/tmp\/vix_l10n\"}" \ --force-scopes --wait-finish .PHONY: translation_templates diff --git a/appinfo/info.xml b/appinfo/info.xml index 264e641..7f68ae6 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,9 @@ Visionatrix - scalable AI Media processing +Each user has their own Visionatrix account (authenticated using Nextcloud credentials) and tasks history. + +Can be used from within Nextcloud Assistant as a regular "txt2img" image provider.]]> 1.0.0 MIT @@ -13,23 +15,28 @@ Each user has their own Visionatrix account (authenticated using Nextcloud crede Alexander Piskun Visionatrix tools - https://github.com/cloud-py-api/visionatrix-nc - https://github.com/cloud-py-api/visionatrix-nc/issues - https://github.com/cloud-py-api/visionatrix-nc + https://github.com/cloud-py-api/visionatrix + https://github.com/cloud-py-api/visionatrix/issues + https://github.com/cloud-py-api/visionatrix - + ghcr.io cloud-py-api/visionatrix - 0.7.1 + 1.0.0 AI_PROVIDERS FILES USER_INFO - false + + .* + GET,POST,PUT,DELETE + USER + [] + diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index b21d533..989396b 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -1,22 +1,39 @@ +import asyncio +import contextlib +import json import os +import string +import subprocess import typing -from contextlib import asynccontextmanager +import zipfile +from base64 import b64encode from contextvars import ContextVar from gettext import translation +from io import BytesIO from pathlib import Path +from time import sleep import httpx -from fastapi import BackgroundTasks, Depends, FastAPI, Request, responses, status +from fastapi import BackgroundTasks, Body, Depends, FastAPI, Request, responses, status from nc_py_api import NextcloudApp -from nc_py_api.ex_app import AppAPIAuthMiddleware, nc_app, run_app +from nc_py_api.ex_app import AppAPIAuthMiddleware, nc_app, persistent_storage, run_app from nc_py_api.ex_app.integration_fastapi import fetch_models_task +from nc_py_api.ex_app.providers.task_processing import TaskProcessingProvider from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import FileResponse, Response +from supported_flows import FLOWS_IDS LOCALE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "locale") current_translator = ContextVar("current_translator") current_translator.set(translation(os.getenv("APP_ID"), LOCALE_DIR, languages=["en"], fallback=True)) +ENABLED_FLAG = NextcloudApp().enabled_state +SUPERUSER_PASSWORD_PATH = Path(persistent_storage()).joinpath("superuser.txt") +SUPERUSER_NAME = "visionatrix_admin" +SUPERUSER_PASSWORD: str = "" +# print(str(SUPERUSER_PASSWORD_PATH), flush=True) # for development only +INSTALLED_FLOWS = [] + def _(text): return current_translator.get().gettext(text) @@ -31,19 +48,27 @@ async def dispatch(self, request: Request, call_next): return await call_next(request) -@asynccontextmanager +@contextlib.asynccontextmanager async def lifespan(_app: FastAPI): + global SUPERUSER_PASSWORD + print(_("Visionatrix")) + SUPERUSER_PASSWORD = Path(SUPERUSER_PASSWORD_PATH).read_text() + _t1 = asyncio.create_task(start_nextcloud_provider_registration()) # noqa + _t2 = asyncio.create_task(start_nextcloud_tasks_polling()) # noqa yield APP = FastAPI(lifespan=lifespan) -APP.add_middleware(AppAPIAuthMiddleware) +APP.add_middleware(AppAPIAuthMiddleware) # noqa # APP.add_middleware(LocalizationMiddleware) def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: + global ENABLED_FLAG + print(f"enabled={enabled}") + ENABLED_FLAG = enabled if enabled: nc.ui.resources.set_script("top_menu", "visionatrix", "ex_app/js/visionatrix-main") nc.ui.top_menu.register("visionatrix", "Visionatrix", "ex_app/img/app.svg") @@ -69,6 +94,50 @@ def enabled_callback(enabled: bool, nc: typing.Annotated[NextcloudApp, Depends(n return responses.JSONResponse(content={"error": enabled_handler(enabled, nc)}) +@APP.post("/webhooks/{nc_task_id}/task-progress") +def get_task_progress( + nc_task_id: int, + task_id: int = Body(...), + progress: float = Body(...), + execution_time: float = Body(...), + error: str = Body(...), +): + nc = NextcloudApp() + if error: + nc.providers.task_processing.report_result(nc_task_id, None, error) + return + if progress == 100.0: + with httpx.Client( + base_url="http://127.0.0.1:8288/api", + auth=httpx.BasicAuth(SUPERUSER_NAME, SUPERUSER_PASSWORD), + ) as client: + vix_task = client.get( + url=f"/tasks/progress/{task_id}", + ) + vix_task_parsed = json.loads(vix_task.content) + vix_task_result = client.get( + url="/tasks/results", + params={ + "task_id": task_id, + "node_id": vix_task_parsed["outputs"][0]["comfy_node_id"], + "batch_index": -1, + }, + ) + zip_file = zipfile.ZipFile(BytesIO(vix_task_result.content)) + results_ids = [] + for file_name in zip_file.namelist(): + results_ids.append( + nc.providers.task_processing.upload_result_file(nc_task_id, zip_file.read(file_name)) + ) + debug_info = nc.providers.task_processing.report_result(nc_task_id, output={"images": results_ids}) + client.delete(url="/tasks/task", params={"task_id": task_id}) + else: + debug_info = nc.providers.task_processing.set_progress(nc_task_id, progress) + print("[DEBUG]: get_task_progress:") + print(debug_info) + print(nc_task_id, " ", task_id, " ", progress, " ", execution_time, " ", error) + + @APP.api_route( "/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"], @@ -112,10 +181,10 @@ async def proxy_backend_requests(request: Request, path: str): methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"], ) async def proxy_requests(_request: Request, path: str): - print( - f"proxy_requests: {path} - {_request.method}\nCookies: {_request.cookies}", - flush=True, - ) + # print( + # f"proxy_requests: {path} - {_request.method}\nCookies: {_request.cookies}", + # flush=True, + # ) if path.startswith("ex_app"): file_server_path = Path("../../" + path) elif not path: @@ -126,10 +195,141 @@ async def proxy_requests(_request: Request, path: str): return Response(status_code=status.HTTP_404_NOT_FOUND) response = FileResponse(str(file_server_path)) response.headers["content-security-policy"] = "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:;" - print("proxy_FRONTEND_requests: Returning: ", str(file_server_path), flush=True) + # print("proxy_FRONTEND_requests: Returning: ", str(file_server_path), flush=True) return response +def background_tasks_polling(): + global ENABLED_FLAG + + basic_auth = httpx.BasicAuth(SUPERUSER_NAME, SUPERUSER_PASSWORD) + nc = NextcloudApp() + ip_address = "127.0.0.1" if os.environ["APP_HOST"] == "0.0.0.0" else os.environ["APP_HOST"] # noqa + webhook_url = f"http://{ip_address}:{os.environ['APP_PORT']}/webhooks" + while True: + while ENABLED_FLAG: + try: + if not poll_tasks(nc, basic_auth, webhook_url): + sleep(1) + except Exception as e: + print(f"poll_tasks: Exception occurred! Info: {e}") + sleep(10) + sleep(30) + ENABLED_FLAG = nc.enabled_state + + +def poll_tasks(nc: NextcloudApp, basic_auth: httpx.BasicAuth, webhook_url: str) -> bool: + reply_from_nc = nc.providers.task_processing.next_task([f"v_{i}" for i in INSTALLED_FLOWS], ["core:text2image"]) + if not reply_from_nc: + return False + task_info = reply_from_nc["task"] + webhook_headers = json.dumps( + { + "AA-VERSION": "3.1.0", + "EX-APP-VERSION": os.environ["APP_VERSION"], + "EX-APP-ID": os.environ["APP_ID"], + "AUTHORIZATION-APP-API": b64encode(f":{os.environ['APP_SECRET']}".encode()).decode(), + } + ) + with httpx.Client(base_url="http://127.0.0.1:8288/api") as client: + vix_task = client.put( + url="/tasks/create", + auth=basic_auth, + data={ + "name": reply_from_nc["provider"]["name"].removeprefix("v_"), + "input_params": json.dumps( + { + "prompt": task_info["input"]["input"], + "batch_size": min(task_info["input"]["numberOfImages"], 4), + } + ), + "webhook_url": webhook_url + f"/{task_info['id']}", + "webhook_headers": webhook_headers, + }, + ) + print("task passed to visionatrix, return code: ", vix_task.status_code, flush=True) + return True + + +async def start_nextcloud_tasks_polling(): + await asyncio.to_thread(background_tasks_polling) + + +def background_provider_registration(): + global ENABLED_FLAG + + basic_auth = httpx.BasicAuth(SUPERUSER_NAME, SUPERUSER_PASSWORD) + nc = NextcloudApp() + + while True: + while ENABLED_FLAG: + try: + sync_providers(nc, basic_auth) + sleep(30) + except Exception as e: + print(f"sync_providers: Exception occurred! Info: {e}") + sleep(60) + sleep(60) + ENABLED_FLAG = nc.enabled_state + + +def sync_providers(nc: NextcloudApp, basic_auth: httpx.BasicAuth) -> None: + global INSTALLED_FLOWS + + with httpx.Client(base_url="http://127.0.0.1:8288/api") as client: + r = client.get( + url="/flows/installed", + auth=basic_auth, + ) + vix_flows = json.loads(r.content) + name_to_display_name = {item["name"]: item["display_name"] for item in vix_flows} + new_flows = set([i["name"] for i in vix_flows if i["name"] in FLOWS_IDS]) # noqa + providers_to_install = list(new_flows - set(INSTALLED_FLOWS)) + providers_to_delete = list(set(INSTALLED_FLOWS) - new_flows) + for i in providers_to_install: + provider_info = TaskProcessingProvider( + id=f"v_{i}", name=f"Visionatrix: {name_to_display_name[i]}", task_type="core:text2image" # noqa + ) + nc.providers.task_processing.register(provider_info) + for i in providers_to_delete: + nc.providers.task_processing.unregister(name=f"v_{i}") + INSTALLED_FLOWS = list(new_flows) + + +async def start_nextcloud_provider_registration(): + await asyncio.to_thread(background_provider_registration) + + +def generate_random_string(length=10): + letters = string.ascii_letters + string.digits # You can include other characters if needed + return "".join(random.choice(letters) for i in range(length)) # noqa + + +def venv_run(command: str) -> None: + command = f". /Visionatrix/venv/bin/activate && {command}" + try: + print(f"executing(pwf={os.getcwd()}): {command}") + subprocess.check_call(command, shell=True) + except subprocess.CalledProcessError as e: + print("An error occurred while executing command in venv:", str(e)) + raise + + +def initialize_visionatrix() -> None: + while True: # Let's wait until Visionatrix opens the port. + with contextlib.suppress(httpx.ReadError, httpx.ConnectError, httpx.RemoteProtocolError): + r = httpx.get("http://127.0.0.1:8288") + if r.status_code in (200, 204, 401, 403): + break + sleep(5) + if not SUPERUSER_PASSWORD_PATH.exists(): + password = generate_random_string() + # password = "12345" # uncomment this line and comment next for the developing with local Visionatrix version. + venv_run(f"python3 -m visionatrix create-user --name {SUPERUSER_NAME} --password {password}") + Path(SUPERUSER_PASSWORD_PATH).write_text(password) + + if __name__ == "__main__": + initialize_visionatrix() os.chdir(Path(__file__).parent) run_app("main:APP", log_level="trace") diff --git a/ex_app/lib/supported_flows.py b/ex_app/lib/supported_flows.py new file mode 100644 index 0000000..33a9432 --- /dev/null +++ b/ex_app/lib/supported_flows.py @@ -0,0 +1,13 @@ +FLOWS_IDS = [ + "flux1_dev", + "flux1_dev_8bit", + "flux1_schnell", + "flux1_schnell_8bit", + "hunyuan_dit", + "juggernaut_lite", + "mobius_xl", + "playground_2_5_aesthetic", + "playground_2_5_prometheus", + "sdxl_lighting", + "stable_cascade", +] diff --git a/pyproject.toml b/pyproject.toml index 433b8cc..162ff78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ preview = true line-length = 120 target-version = "py310" select = ["A", "B", "C", "E", "F", "G", "I", "S", "SIM", "PIE", "Q", "RET", "RUF", "UP" , "W"] -extend-ignore = ["I001", "RUF100", "D400", "D415"] +extend-ignore = ["I001", "RUF100", "D400", "D415", "S602"] [tool.isort] profile = "black" diff --git a/requirements.txt b/requirements.txt index 93e28a2..03cbef7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -nc-py-api[app]>=0.13.0 +nc-py-api[app]>=0.16.0