Files

268 lines
10 KiB
Python

"""
GCS Upload Node for ComfyUI
============================
Uploads generated images directly from ComfyUI (RunPod) to Google Cloud Storage,
then notifies your Supabase database — completely bypassing the Supabase Edge Function.
This eliminates Supabase Egress costs for image data.
SETUP:
1. Copy config.json.example → config.json in this directory.
2. Fill in your Supabase URL and Service Role Key.
3. Set your GCS credentials in the web dashboard (they are fetched at runtime).
"""
import os
import io
import json
import time
import traceback
import importlib.util
from pathlib import Path
import requests
import numpy as np
from PIL import Image
# ─── Config Loading ────────────────────────────────────────────────────────────
CONFIG_PATH = Path(__file__).parent / "config.json"
def load_config() -> dict:
if not CONFIG_PATH.exists():
raise FileNotFoundError(
f"[GCS Uploader] config.json not found at {CONFIG_PATH}. "
"Copy config.json.example → config.json and fill in your Supabase credentials."
)
with open(CONFIG_PATH, "r") as f:
return json.load(f)
# ─── Supabase Helpers ──────────────────────────────────────────────────────────
def fetch_gcs_config(supabase_url: str, service_key: str) -> dict:
"""Fetches GCS credentials from the system_configs table in Supabase."""
headers = {
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
}
params = {
"key_name": "in.(GCS_PROJECT_ID,GCS_CLIENT_EMAIL,GCS_PRIVATE_KEY,GCS_BUCKET_NAME)",
"select": "key_name,key_value",
}
resp = requests.get(f"{supabase_url}/rest/v1/system_configs", headers=headers, params=params, timeout=10)
resp.raise_for_status()
rows = resp.json()
config = {r["key_name"]: r["key_value"] for r in rows}
required = ["GCS_PROJECT_ID", "GCS_CLIENT_EMAIL", "GCS_PRIVATE_KEY", "GCS_BUCKET_NAME"]
missing = [k for k in required if not config.get(k)]
if missing:
raise ValueError(f"[GCS Uploader] Missing GCS config in system_configs: {missing}")
# Fix escaped newlines in private key
config["GCS_PRIVATE_KEY"] = config["GCS_PRIVATE_KEY"].replace("\\n", "\n")
return config
def mark_job_completed(supabase_url: str, service_key: str, job_id: str, content_item_id: str,
image_type: str, slot_index: int, gcs_url: str, file_name: str):
"""Updates production_jobs and inserts into generated_images."""
headers = {
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
"Content-Type": "application/json",
"Prefer": "resolution=merge-duplicates",
}
# 1. Upsert into generated_images
image_payload = {
"content_item_id": content_item_id,
"image_type": image_type,
"file_path": gcs_url,
"file_name": file_name,
"slot_index": slot_index,
"status": "Generated",
"media_type": "image",
}
img_resp = requests.post(
f"{supabase_url}/rest/v1/generated_images",
headers={**headers, "Prefer": "resolution=merge-duplicates,return=minimal"},
json=image_payload,
timeout=10,
)
img_resp.raise_for_status()
# 2. Mark production_job as Completed
job_resp = requests.patch(
f"{supabase_url}/rest/v1/production_jobs?id=eq.{job_id}",
headers=headers,
json={"status": "Completed", "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())},
timeout=10,
)
job_resp.raise_for_status()
def log_to_supabase(supabase_url: str, service_key: str, level: str, phase: str, message: str):
"""Writes a log entry to system_logs table."""
try:
headers = {
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
"Content-Type": "application/json",
}
requests.post(
f"{supabase_url}/rest/v1/system_logs",
headers=headers,
json={"level": level, "phase": phase, "message": message},
timeout=5,
)
except Exception:
pass # Logging failure should not fail the node
# ─── GCS Upload ───────────────────────────────────────────────────────────────
def upload_to_gcs(image_bytes: bytes, gcs_config: dict, object_path: str) -> str:
"""Uploads raw image bytes to GCS and returns the public URL."""
from google.cloud import storage
from google.oauth2 import service_account
credentials = service_account.Credentials.from_service_account_info(
{
"type": "service_account",
"project_id": gcs_config["GCS_PROJECT_ID"],
"private_key_id": "key",
"private_key": gcs_config["GCS_PRIVATE_KEY"],
"client_email": gcs_config["GCS_CLIENT_EMAIL"],
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
},
scopes=["https://www.googleapis.com/auth/devstorage.read_write"],
)
client = storage.Client(project=gcs_config["GCS_PROJECT_ID"], credentials=credentials)
bucket = client.bucket(gcs_config["GCS_BUCKET_NAME"])
blob = bucket.blob(object_path)
blob.upload_from_string(image_bytes, content_type="image/png")
return f"https://storage.googleapis.com/{gcs_config['GCS_BUCKET_NAME']}/{object_path}"
# ─── ComfyUI Node Definition ──────────────────────────────────────────────────
class GCSUploadNode:
"""
ComfyUI node that uploads the generated image directly to Google Cloud Storage
and notifies your Supabase database — no Supabase Egress cost!
"""
CATEGORY = "AI Influencer / Storage"
FUNCTION = "upload"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("gcs_url",)
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"job_id": ("STRING", {"default": "", "multiline": False}),
"content_item_id": ("STRING", {"default": "", "multiline": False}),
"image_type": (["SFW", "NSFW"],),
"slot_index": ("INT", {"default": 0, "min": 0, "max": 16}),
},
}
def upload(
self,
image,
job_id: str,
content_item_id: str,
image_type: str,
slot_index: int,
):
print(f"\n[GCS Uploader] 🚀 Starting upload for job {job_id} (slot {slot_index}, {image_type})")
try:
# 1. Load config
cfg = load_config()
supabase_url = cfg["supabase_url"].rstrip("/")
service_key = cfg["supabase_service_role_key"]
# 2. Fetch GCS credentials from Supabase
print("[GCS Uploader] 🔑 Fetching GCS config from Supabase...")
gcs_config = fetch_gcs_config(supabase_url, service_key)
# 3. Convert ComfyUI tensor → PNG bytes
# image tensor shape: (batch, H, W, C) float32 in [0,1]
img_np = (image[0].cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
pil_img = Image.fromarray(img_np, mode="RGB")
buffer = io.BytesIO()
pil_img.save(buffer, format="PNG")
image_bytes = buffer.getvalue()
print(f"[GCS Uploader] 🖼️ Image size: {len(image_bytes) / 1024:.1f} KB")
# 4. Build GCS object path
object_path = f"images/{content_item_id}/{image_type}/{job_id}_{slot_index}.png"
# 5. Upload to GCS
print(f"[GCS Uploader] 📤 Uploading to GCS: {object_path}")
gcs_url = upload_to_gcs(image_bytes, gcs_config, object_path)
print(f"[GCS Uploader] ✅ Uploaded: {gcs_url}")
# 6. Update Supabase DB
print("[GCS Uploader] 💾 Updating Supabase DB...")
mark_job_completed(
supabase_url, service_key,
job_id=job_id,
content_item_id=content_item_id,
image_type=image_type,
slot_index=slot_index,
gcs_url=gcs_url,
file_name=object_path,
)
print("[GCS Uploader] ✅ Job marked Completed in DB!")
# 7. Log success
log_to_supabase(
supabase_url, service_key,
"SUCCESS", "ComfyUI Node",
f"Job {job_id} completed. Image uploaded to GCS: {object_path}"
)
return (gcs_url,)
except Exception as e:
err_msg = f"[GCS Uploader] ❌ Upload failed for job {job_id}: {e}"
print(err_msg)
traceback.print_exc()
# Try to mark job as Failed in DB
try:
cfg = load_config()
supabase_url = cfg["supabase_url"].rstrip("/")
service_key = cfg["supabase_service_role_key"]
headers = {
"apikey": service_key,
"Authorization": f"Bearer {service_key}",
"Content-Type": "application/json",
}
requests.patch(
f"{supabase_url}/rest/v1/production_jobs?id=eq.{job_id}",
headers=headers,
json={
"status": "Failed",
"error_message": str(e),
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
},
timeout=5,
)
log_to_supabase(supabase_url, service_key, "ERROR", "ComfyUI Node", err_msg)
except Exception:
pass
raise RuntimeError(err_msg) from e