Spaces:
Running
Running
Upload folder via script
Browse files- velai/services/image/FalAIImageGenerator.py +8 -8
- velai/session.py +17 -6
- velai/ui/training.py +35 -5
velai/services/image/FalAIImageGenerator.py
CHANGED
|
@@ -266,7 +266,7 @@ class FalAIKrea2TurboGenerator(FalAIImageGenerator):
|
|
| 266 |
|
| 267 |
@register_service(ImageGenerator)
|
| 268 |
class FalAINanoBananaGenerator(FalAITextAndEditImageGenerator):
|
| 269 |
-
"""fal-ai/nano-banana text and edit combination."""
|
| 270 |
|
| 271 |
@classmethod
|
| 272 |
def get_service_id(cls) -> str:
|
|
@@ -274,24 +274,24 @@ class FalAINanoBananaGenerator(FalAITextAndEditImageGenerator):
|
|
| 274 |
|
| 275 |
def __init__(self, api_key: str | None = None) -> None:
|
| 276 |
super().__init__(
|
| 277 |
-
service_model_name="fal-ai/nano-banana",
|
| 278 |
api_key=api_key,
|
| 279 |
-
text_model="fal-ai/nano-banana",
|
| 280 |
-
edit_model="fal-ai/nano-banana/edit",
|
| 281 |
image_argument="image_urls",
|
| 282 |
)
|
| 283 |
|
| 284 |
@classmethod
|
| 285 |
def get_name(cls) -> str:
|
| 286 |
-
return "Nano Banana"
|
| 287 |
|
| 288 |
@classmethod
|
| 289 |
def default_model_name(cls) -> str:
|
| 290 |
-
return "fal-ai/nano-banana"
|
| 291 |
|
| 292 |
def _calculate_cost(self, model: str, input_data: ImageGenerationInput, response: dict[str, Any]) -> float | None:
|
| 293 |
-
#
|
| 294 |
-
return 0.
|
| 295 |
|
| 296 |
|
| 297 |
class FalAIReveGenerator(FalAIImageGenerator):
|
|
|
|
| 266 |
|
| 267 |
@register_service(ImageGenerator)
|
| 268 |
class FalAINanoBananaGenerator(FalAITextAndEditImageGenerator):
|
| 269 |
+
"""fal-ai/nano-banana-2 text and edit combination (Gemini 3.1 Flash Image)."""
|
| 270 |
|
| 271 |
@classmethod
|
| 272 |
def get_service_id(cls) -> str:
|
|
|
|
| 274 |
|
| 275 |
def __init__(self, api_key: str | None = None) -> None:
|
| 276 |
super().__init__(
|
| 277 |
+
service_model_name="fal-ai/nano-banana-2",
|
| 278 |
api_key=api_key,
|
| 279 |
+
text_model="fal-ai/nano-banana-2",
|
| 280 |
+
edit_model="fal-ai/nano-banana-2/edit",
|
| 281 |
image_argument="image_urls",
|
| 282 |
)
|
| 283 |
|
| 284 |
@classmethod
|
| 285 |
def get_name(cls) -> str:
|
| 286 |
+
return "Nano Banana 2"
|
| 287 |
|
| 288 |
@classmethod
|
| 289 |
def default_model_name(cls) -> str:
|
| 290 |
+
return "fal-ai/nano-banana-2"
|
| 291 |
|
| 292 |
def _calculate_cost(self, model: str, input_data: ImageGenerationInput, response: dict[str, Any]) -> float | None:
|
| 293 |
+
# 1K list price on fal; 2K/4K are higher.
|
| 294 |
+
return 0.08
|
| 295 |
|
| 296 |
|
| 297 |
class FalAIReveGenerator(FalAIImageGenerator):
|
velai/session.py
CHANGED
|
@@ -542,7 +542,11 @@ class GraphSession:
|
|
| 542 |
def duplicate_nodes(
|
| 543 |
self, source_node_ids: list[str], position: dict[str, Any] | None = None
|
| 544 |
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
| 545 |
-
"""Duplicate nodes and recreate
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
source_ids = {node_id for node_id in source_node_ids if node_id}
|
| 547 |
sources = [self.graph.nodes[node_id] for node_id in source_node_ids if node_id in self.graph.nodes]
|
| 548 |
if not sources:
|
|
@@ -592,16 +596,23 @@ class GraphSession:
|
|
| 592 |
vue_nodes.append(self.renderer.to_vue_node(dup_node))
|
| 593 |
|
| 594 |
vue_edges: list[dict[str, Any]] = []
|
| 595 |
-
|
|
|
|
| 596 |
src_id = conn.start_node.node_id
|
| 597 |
tgt_id = conn.end_node.node_id
|
| 598 |
-
if src_id not in source_ids
|
| 599 |
continue
|
| 600 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
continue
|
| 602 |
|
| 603 |
-
new_start = self.graph.nodes
|
| 604 |
-
new_end = self.graph.nodes
|
|
|
|
|
|
|
| 605 |
|
| 606 |
start_port = new_start.outputs.get(conn.start_port.name) if new_start.outputs else None
|
| 607 |
if start_port is None and new_start.inputs:
|
|
|
|
| 542 |
def duplicate_nodes(
|
| 543 |
self, source_node_ids: list[str], position: dict[str, Any] | None = None
|
| 544 |
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
| 545 |
+
"""Duplicate nodes and recreate their connections.
|
| 546 |
+
|
| 547 |
+
Keeps edges between copied nodes, and also rewires edges that linked a
|
| 548 |
+
copied node to an uncopied neighbor onto the duplicate.
|
| 549 |
+
"""
|
| 550 |
source_ids = {node_id for node_id in source_node_ids if node_id}
|
| 551 |
sources = [self.graph.nodes[node_id] for node_id in source_node_ids if node_id in self.graph.nodes]
|
| 552 |
if not sources:
|
|
|
|
| 596 |
vue_nodes.append(self.renderer.to_vue_node(dup_node))
|
| 597 |
|
| 598 |
vue_edges: list[dict[str, Any]] = []
|
| 599 |
+
# Snapshot: we append while iterating.
|
| 600 |
+
for conn in list(self.graph.connections):
|
| 601 |
src_id = conn.start_node.node_id
|
| 602 |
tgt_id = conn.end_node.node_id
|
| 603 |
+
if src_id not in source_ids and tgt_id not in source_ids:
|
| 604 |
continue
|
| 605 |
+
|
| 606 |
+
new_src_id = id_map.get(src_id, src_id)
|
| 607 |
+
new_tgt_id = id_map.get(tgt_id, tgt_id)
|
| 608 |
+
# Need at least one remapped endpoint (a duplicated node).
|
| 609 |
+
if new_src_id == src_id and new_tgt_id == tgt_id:
|
| 610 |
continue
|
| 611 |
|
| 612 |
+
new_start = self.graph.nodes.get(new_src_id)
|
| 613 |
+
new_end = self.graph.nodes.get(new_tgt_id)
|
| 614 |
+
if new_start is None or new_end is None:
|
| 615 |
+
continue
|
| 616 |
|
| 617 |
start_port = new_start.outputs.get(conn.start_port.name) if new_start.outputs else None
|
| 618 |
if start_port is None and new_start.inputs:
|
velai/ui/training.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import os
|
|
|
|
| 5 |
from typing import TYPE_CHECKING, List, Optional
|
| 6 |
|
| 7 |
import fal_client
|
|
@@ -19,6 +20,15 @@ KREA_TRAINER_MODEL = "fal-ai/krea-2-trainer"
|
|
| 19 |
MAX_ZIP_BYTES = MAX_DATASET_ZIP_MB * 1024 * 1024
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
class TrainingDialog:
|
| 23 |
def __init__(self, on_library_changed=None, app_ctx: "AppContext | None" = None) -> None:
|
| 24 |
self.zip_url: Optional[str] = None
|
|
@@ -161,7 +171,7 @@ class TrainingDialog:
|
|
| 161 |
with ui.row().classes("gap-1"):
|
| 162 |
ui.button(
|
| 163 |
icon="download",
|
| 164 |
-
on_click=lambda e=entry:
|
| 165 |
).props("flat dense")
|
| 166 |
ui.button(
|
| 167 |
icon="delete",
|
|
@@ -174,6 +184,16 @@ class TrainingDialog:
|
|
| 174 |
if asyncio.iscoroutine(result):
|
| 175 |
asyncio.create_task(result)
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
def _delete_entry(self, entry: LoraEntry) -> None:
|
| 178 |
delete_lora(self._user_storage(), entry.id)
|
| 179 |
ui.notify(f"Removed {entry.name}", type="info")
|
|
@@ -337,13 +357,13 @@ class TrainingDialog:
|
|
| 337 |
if not lora_remote:
|
| 338 |
raise RuntimeError("Trainer did not return LoRA weights")
|
| 339 |
|
| 340 |
-
|
|
|
|
| 341 |
stored_lora = await storage_endpoint.download_and_store_url(
|
| 342 |
lora_remote,
|
| 343 |
-
original_name=
|
| 344 |
mime_type="application/octet-stream",
|
| 345 |
)
|
| 346 |
-
name = (self.lora_name.value or "My LoRA").strip()
|
| 347 |
add_lora(
|
| 348 |
self._user_storage(),
|
| 349 |
name=name,
|
|
@@ -354,7 +374,9 @@ class TrainingDialog:
|
|
| 354 |
)
|
| 355 |
|
| 356 |
self.result_container.set_visibility(True)
|
| 357 |
-
self.download_lora_btn.on_click(
|
|
|
|
|
|
|
| 358 |
if config_remote:
|
| 359 |
self.download_config_btn.on_click(
|
| 360 |
lambda: ui.download(config_remote, config_file.get("file_name") or "config.json")
|
|
@@ -363,6 +385,14 @@ class TrainingDialog:
|
|
| 363 |
else:
|
| 364 |
self.download_config_btn.disable()
|
| 365 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
def open(self) -> None:
|
| 367 |
self.refresh_library()
|
| 368 |
self.dialog.open()
|
|
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import os
|
| 5 |
+
import re
|
| 6 |
from typing import TYPE_CHECKING, List, Optional
|
| 7 |
|
| 8 |
import fal_client
|
|
|
|
| 20 |
MAX_ZIP_BYTES = MAX_DATASET_ZIP_MB * 1024 * 1024
|
| 21 |
|
| 22 |
|
| 23 |
+
def lora_download_filename(name: str) -> str:
|
| 24 |
+
"""Build a safe download filename from the user-facing LoRA name."""
|
| 25 |
+
base = (name or "lora").strip() or "lora"
|
| 26 |
+
base = re.sub(r"[^\w.\- ]+", "_", base).strip(" ._") or "lora"
|
| 27 |
+
if not base.lower().endswith(".safetensors"):
|
| 28 |
+
base = f"{base}.safetensors"
|
| 29 |
+
return base
|
| 30 |
+
|
| 31 |
+
|
| 32 |
class TrainingDialog:
|
| 33 |
def __init__(self, on_library_changed=None, app_ctx: "AppContext | None" = None) -> None:
|
| 34 |
self.zip_url: Optional[str] = None
|
|
|
|
| 171 |
with ui.row().classes("gap-1"):
|
| 172 |
ui.button(
|
| 173 |
icon="download",
|
| 174 |
+
on_click=lambda e=entry: asyncio.create_task(self._download_entry(e)),
|
| 175 |
).props("flat dense")
|
| 176 |
ui.button(
|
| 177 |
icon="delete",
|
|
|
|
| 184 |
if asyncio.iscoroutine(result):
|
| 185 |
asyncio.create_task(result)
|
| 186 |
|
| 187 |
+
async def _download_entry(self, entry: LoraEntry) -> None:
|
| 188 |
+
filename = lora_download_filename(entry.name)
|
| 189 |
+
try:
|
| 190 |
+
blob = await storage_endpoint.get_blob_by_url(entry.url)
|
| 191 |
+
data = await blob.read_bytes()
|
| 192 |
+
ui.download(data, filename, "application/octet-stream")
|
| 193 |
+
except Exception:
|
| 194 |
+
src = entry.fal_url if entry.fal_url.startswith("http") else entry.url
|
| 195 |
+
ui.download(src, filename, "application/octet-stream")
|
| 196 |
+
|
| 197 |
def _delete_entry(self, entry: LoraEntry) -> None:
|
| 198 |
delete_lora(self._user_storage(), entry.id)
|
| 199 |
ui.notify(f"Removed {entry.name}", type="info")
|
|
|
|
| 357 |
if not lora_remote:
|
| 358 |
raise RuntimeError("Trainer did not return LoRA weights")
|
| 359 |
|
| 360 |
+
name = (self.lora_name.value or "My LoRA").strip()
|
| 361 |
+
filename = lora_download_filename(name)
|
| 362 |
stored_lora = await storage_endpoint.download_and_store_url(
|
| 363 |
lora_remote,
|
| 364 |
+
original_name=filename,
|
| 365 |
mime_type="application/octet-stream",
|
| 366 |
)
|
|
|
|
| 367 |
add_lora(
|
| 368 |
self._user_storage(),
|
| 369 |
name=name,
|
|
|
|
| 374 |
)
|
| 375 |
|
| 376 |
self.result_container.set_visibility(True)
|
| 377 |
+
self.download_lora_btn.on_click(
|
| 378 |
+
lambda: asyncio.create_task(self._download_stored_lora(stored_lora, filename))
|
| 379 |
+
)
|
| 380 |
if config_remote:
|
| 381 |
self.download_config_btn.on_click(
|
| 382 |
lambda: ui.download(config_remote, config_file.get("file_name") or "config.json")
|
|
|
|
| 385 |
else:
|
| 386 |
self.download_config_btn.disable()
|
| 387 |
|
| 388 |
+
async def _download_stored_lora(self, stored_url: str, filename: str) -> None:
|
| 389 |
+
try:
|
| 390 |
+
blob = await storage_endpoint.get_blob_by_url(stored_url)
|
| 391 |
+
data = await blob.read_bytes()
|
| 392 |
+
ui.download(data, filename, "application/octet-stream")
|
| 393 |
+
except Exception:
|
| 394 |
+
ui.download(stored_url, filename, "application/octet-stream")
|
| 395 |
+
|
| 396 |
def open(self) -> None:
|
| 397 |
self.refresh_library()
|
| 398 |
self.dialog.open()
|