Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions acestep/lora_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)


Expand Down
21 changes: 21 additions & 0 deletions acestep/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
35 changes: 35 additions & 0 deletions acestep/streaming/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_<id> 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,
Expand Down
36 changes: 36 additions & 0 deletions demos/realtime_motion_graph_web/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,42 @@ class EventSpec:
requires="lora",
description="Disable a LoRA and drop its lora_str_<id> 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 "
"<style>.signature.json sidecar."),
FieldSpec("kid", "str", required=True,
description="Expected signing key id; must be in "
"the pod's trusted set."),
FieldSpec("sha256", "str", required=True,
description="Expected hex digest of the safetensors; "
"must match the value inside the signed "
"manifest."),
),
requires="lora",
description="Download a user-trained LoRA from Tigris, verify the "
"Ed25519 signature, drop it into the user-loras dir, "
"and register it with the live engine. The refreshed "
"lora_catalog event is then broadcast to all WS "
"clients. Failures surface as {type:'error'} with "
"code register_user_lora_* (no catalog mutation).",
),
CommandSpec(
"manual_slot_add",
requires="steering",
Expand Down
Loading