diff --git a/acestep/lora_metadata.py b/acestep/lora_metadata.py index f8ca8ba5..312ecc9c 100644 --- a/acestep/lora_metadata.py +++ b/acestep/lora_metadata.py @@ -87,6 +87,14 @@ class LoraMetadata: # callers distinguish "rich metadata" from "synthesized fallback" # without inspecting individual field nullity. has_metadata: bool = False + # Provenance tag: ``"user_pack"`` for LoRAs the pod received via + # the runtime ``register_user_lora`` WS message (training output + # the user trained themselves), or ``None`` for everything else + # (the baked-in stock catalog discovered at boot from + # :func:`acestep.paths.loras_dir`). Surfaced to UI clients so + # they can group the catalog into "My Styles" vs "Stock Styles" + # sections. + source: Optional[str] = None def to_wire(self) -> dict[str, Any]: """JSON-safe dict for shipping to the UI / MCP clients.""" @@ -209,6 +217,14 @@ def _from_schema(raw: dict[str, Any], stem: str) -> LoraMetadata: cls = raw.get("classification") or {} model = raw.get("model") or {} + # ``source`` is a non-schema-v1 extension we write into sidecars for + # LoRAs the runtime registers via ``register_user_lora`` (see + # ``demos/realtime_motion_graph_web/user_loras.py``). Stock sidecars + # omit it; the field defaults to None which clients read as "stock". + source = raw.get("source") + if source is not None and not isinstance(source, str): + source = None + return LoraMetadata( id=stem, name=raw.get("name") or stem, @@ -226,6 +242,7 @@ def _from_schema(raw: dict[str, Any], stem: str) -> LoraMetadata: base_model=model.get("base_model"), base_model_scale=model.get("base_model_scale"), has_metadata=True, + source=source, ) diff --git a/acestep/paths.py b/acestep/paths.py index 3869e2c4..e3218587 100644 --- a/acestep/paths.py +++ b/acestep/paths.py @@ -142,6 +142,27 @@ def loras_dir() -> Path: return models_dir() / "loras" +def user_loras_dir() -> Path: + """Directory the pod writes verified user-uploaded LoRAs into. + + Held separate from :func:`loras_dir` so the stock library shipped + in the pod image stays read-only and an operator can mount this + dir on persistent storage without affecting boot-time discovery. + Auto-included in :func:`discover_all_loras` (via ``extra_lora_dirs``- + like inclusion in register_library) so the catalog event surfaces + user packs alongside stock LoRAs after registration. + + Defaults to ``$ACESTEP_MODELS_DIR/user_loras``; override with + ``ACESTEP_USER_LORAS_DIR`` for non-default volume mounts. The + directory is created lazily by callers (the WS register handler + creates it on first write). + """ + override = os.environ.get("ACESTEP_USER_LORAS_DIR", "").strip() + if override: + return Path(os.path.expanduser(override)) + return models_dir() / "user_loras" + + def melband_roformer_dir() -> Path: """Directory containing the Mel-Band RoFormer stem-separation checkpoint.""" return models_dir() / "MelBandRoFormer" diff --git a/acestep/streaming/session.py b/acestep/streaming/session.py index 69dbe04e..9f7469be 100644 --- a/acestep/streaming/session.py +++ b/acestep/streaming/session.py @@ -1684,6 +1684,41 @@ def disable_lora( origin.value, lora_id, ) + @requires_capability("lora", "register_user_lora") + def register_user_lora( + self, + path: str, + *, + name: str | None = None, + origin: CommandOrigin = CommandOrigin.PRIMARY, + ) -> str: + """Register a user-uploaded LoRA at ``path`` with the live engine + and publish a refreshed catalog so all WS clients pick it up. + + Used by the ``register_user_lora`` WS message after the file has + been downloaded + Ed25519-verified by + :mod:`demos.realtime_motion_graph_web.user_loras`. Registration + is synchronous (no GPU work — just catalog bookkeeping) and + idempotent on the filename stem, so a retried message is a + no-op rather than an error. + + Returns the LoRA id (filename stem) the engine now knows about. + """ + if not self.lora_available or self.engine_obj is None: + raise RuntimeError("LoRA is not available on this engine") + self.state.last_activity_ts = time.monotonic() + lora_id = self.engine_obj.register_lora(str(path), name=name) + logger.info( + "register_user_lora_requested origin={} id={} path={}", + origin.value, lora_id, path, + ) + # Mirror _apply_lora_pending's refresh sequence so the catalog + # event reaches all WS subscribers with the new entry, and the + # validation-spec map covers any future lora_str_ knob. + self._rebuild_knob_specs(self._enabled_lora_ids()) + self.bus.publish(LoraCatalogUpdate(catalog=self.lora_catalog_payload())) + return lora_id + @requires_capability("steering", "manual_slot_add") def manual_slot_add( self, diff --git a/demos/realtime_motion_graph_web/protocol.py b/demos/realtime_motion_graph_web/protocol.py index a762f7b1..c1a43b9c 100644 --- a/demos/realtime_motion_graph_web/protocol.py +++ b/demos/realtime_motion_graph_web/protocol.py @@ -329,6 +329,42 @@ class EventSpec: requires="lora", description="Disable a LoRA and drop its lora_str_ knob.", ), + CommandSpec( + "register_user_lora", + fields=( + FieldSpec("id", "str", required=True, + description="Stable id used as the filename stem and " + "the runtime LoRA id (enable/disable). " + "The registry-row UUID is canonical."), + FieldSpec("name", "str", + description="Display name shown in the catalog " + "dropdown — typically the user's chosen " + "style name."), + FieldSpec("trigger", "str", nullable=True, + description="Primary trigger word (4-16 chars). " + "Optional; matches the orchestrator's " + "triggerTag."), + FieldSpec("safetensors_url", "str", required=True, + description="Presigned Tigris GET, ~10min TTL."), + FieldSpec("signature_url", "str", required=True, + description="Presigned Tigris GET for " + "