From a78aed8e9e2e7ce1f2b462e78b75cdf0e4934d4a Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:33:57 +0200 Subject: [PATCH] Add exclusive LTX-2 video studio profile --- compose.yaml | 3 + docs/OPERATING_MODES.md | 19 +++++- .../profile-controller/profile_controller.py | 64 +++++++++++++++++++ .../docker/wireguard-gateway/entrypoint.sh | 1 + .../references/architecture-and-modes.md | 5 +- platform/llama-dashboard/app.py | 12 ++-- platform/ltx2-studio/Dockerfile | 34 ++++++++++ platform/ltx2-studio/README.md | 16 +++++ platform/ltx2-studio/compose.yaml | 35 ++++++++++ platform/ltx2-studio/entrypoint.sh | 30 +++++++++ platform/recovery/rebuild-specialized.sh | 2 + router/ai_profile_router.py | 27 ++++++-- 12 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 platform/ltx2-studio/Dockerfile create mode 100644 platform/ltx2-studio/README.md create mode 100644 platform/ltx2-studio/compose.yaml create mode 100644 platform/ltx2-studio/entrypoint.sh diff --git a/compose.yaml b/compose.yaml index 538478a..273243b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -498,6 +498,7 @@ services: VOICE_CHANGE_WORKER: xvc APPLIO_WORKER: applio TRELLIS_WORKER: trellis2-q8 + VIDEO_WORKER: ltx2 networks: [control] security_opt: ["no-new-privileges:true"] healthcheck: @@ -535,6 +536,7 @@ services: REQUEST_TIMEOUT: "600" YUE2_START_TIMEOUT: "600" TRELLIS_START_TIMEOUT: "900" + VIDEO_START_TIMEOUT: "900" # Last-resort guard for every OpenAI-compatible client. Without a # request limit llama.cpp uses n_predict=-1 and a reasoning loop can # consume the complete context before yielding visible output. @@ -759,6 +761,7 @@ services: MIKES_APPLIO_UI_URL: "${MIKES_APPLIO_UI_URL:-http://192.168.1.212:8012/}" TRELLIS_UI_URL: "${TRELLIS_UI_URL:-http://192.168.1.212:8013/}" YUE2_UI_URL: "${YUE2_UI_URL:-http://192.168.1.212:8014/}" + LTX2_UI_URL: "${LTX2_UI_URL:-http://192.168.1.212:8015/}" HOST_PROC: /host/proc HOST_DATA: /host/data HOST_MODELS: /host/models diff --git a/docs/OPERATING_MODES.md b/docs/OPERATING_MODES.md index 8f5d629..c99f8a2 100644 --- a/docs/OPERATING_MODES.md +++ b/docs/OPERATING_MODES.md @@ -1,6 +1,6 @@ # Athena-Betriebsmodi -Athena besitzt sechs gegenseitig exklusive Betriebsmodi: +Athena besitzt gegenseitig exklusive Betriebsmodi: - `llm`: ein llama.cpp-Profil und Qwen3-TTS laufen; Spezialdienste sind gestoppt. - `music`: ACE-Step 1.5 XL-SFT läuft; alle LLM-, Bild-, TTS- und Separator-Worker sind gestoppt. @@ -13,6 +13,8 @@ Athena besitzt sechs gegenseitig exklusive Betriebsmodi: GPU-Dienste sind gestoppt. - `applio`: Applio stellt RVC-Inferenz, Modellverwaltung und Training bereit. Alle anderen GPU-Dienste sind gestoppt. +- `video`: LTX Desktop erzeugt mit LTX-2 kurze Videos. Beim Start werden alle + LLM-, Bild-, TTS-, Musik-, Sprach-, RVC- und 3D-GPU-Dienste gestoppt. Die Zustandsmaschine lebt im Athena-Router. Das Dashboard und Chat-Clients wie Hermes sind nur Bedienoberflächen derselben API. Der zuletzt aktive LLM-Modus @@ -21,7 +23,7 @@ wird persistent gespeichert und beim Verlassen eines Spezialmodus wieder geladen ## Bedienung Im Athena-Dashboard stehen **LLM-Betrieb**, **Musikstudio**, **Audio trennen**, -**Voice Studio**, **X-VC** und **Applio / RVC** bereit. Im Musikmodus werden zwei Oberflächen angeboten: +**Voice Studio**, **X-VC**, **Applio / RVC**, **3D Studio** und **LTX-2 Video** bereit. Im Musikmodus werden zwei Oberflächen angeboten: - **Original UI · stabil** öffnet die zum laufenden ACE-Step-Image gehörende Gradio-Oberfläche. Sie ist für Cover, Remix und erweiterte Workflows der @@ -55,6 +57,14 @@ nicht eine reine Synthesizer-Spur. Sprache nutzt das 48-kHz-Modell `audio-separator` 0.47.0. Die ältere API-Auswahl kompletter 2-/4-/6-Stem-Sätze bleibt rückwärtskompatibel. +Das LTX-2 Studio ist ausschließlich unter `http://192.168.1.212:8015` +erreichbar. Es verwendet das offizielle LTX Desktop 1.2.7 und speichert Modelle, +Einstellungen und Ergebnisse unter `/data/video/ltx-desktop`. Die RTX 5080 liegt +mit 16 GiB am offiziellen Minimum. Daher ist LTX Fast mit höchstens etwa zehn +Sekunden und 720p oder kleiner der Startpunkt; längere oder größere Läufe sind +nicht zugesichert. Der erste Modelldownload kann eine Hugging-Face-Anmeldung und +die Annahme der Lightricks-Modelllizenz verlangen. + Das Voice Studio ist ausschließlich über den privaten WireGuard-Pfad unter `http://192.168.1.212:8008` erreichbar. Referenzstimmen werden unter `/data/voice/studio/profiles` gespeichert. Die Oberfläche verlangt vor dem @@ -89,6 +99,7 @@ Router lokal beantwortet, auch wenn gerade kein LLM geladen ist: /athena voice /athena voicechange /athena applio +/athena ltx2 /athena llm /athena status ``` @@ -102,6 +113,7 @@ POST /mode {"mode":"separation"} POST /mode {"mode":"voice"} POST /mode {"mode":"voicechange"} POST /mode {"mode":"applio"} +POST /mode {"mode":"video"} POST /mode {"mode":"llm"} ``` @@ -111,7 +123,8 @@ Der Wechsel läuft asynchron. Fortschritt und Fehler stehen unter `mode` in `com.mike-ai.stem-separator=bs-roformer` oder `com.mike-ai.voice-worker=vevo2` beziehungsweise `com.mike-ai.voice-change-worker=xvc` oder -`com.mike-ai.applio-worker=applio` markierten Container; freie +`com.mike-ai.applio-worker=applio` beziehungsweise +`com.mike-ai.video-worker=ltx2` markierten Container; freie Container- oder Docker-Befehle werden nicht entgegengenommen. ## Wiederanlauf diff --git a/platform/docker/profile-controller/profile_controller.py b/platform/docker/profile-controller/profile_controller.py index 4fb3660..f0f1a3b 100644 --- a/platform/docker/profile-controller/profile_controller.py +++ b/platform/docker/profile-controller/profile_controller.py @@ -42,6 +42,8 @@ APPLIO_LABEL_KEY = "com.mike-ai.applio-worker" APPLIO_WORKER = os.environ.get("APPLIO_WORKER", "").strip() TRELLIS_LABEL_KEY = "com.mike-ai.trellis-worker" TRELLIS_WORKER = os.environ.get("TRELLIS_WORKER", "").strip() +VIDEO_LABEL_KEY = "com.mike-ai.video-worker" +VIDEO_WORKER = os.environ.get("VIDEO_WORKER", "").strip() LOCK = threading.Lock() log = logging.getLogger("profile-controller") @@ -187,6 +189,22 @@ def trellis_container() -> dict: return matches[0] +def video_container() -> dict: + if not VIDEO_WORKER: + raise RuntimeError("video worker is not configured") + matches = [item for item in labelled_containers(VIDEO_LABEL_KEY) + if item.get("Labels", {}).get(VIDEO_LABEL_KEY) == VIDEO_WORKER] + if len(matches) != 1: + raise RuntimeError( + f"expected exactly one video worker {VIDEO_WORKER!r}, found {len(matches)}") + return matches[0] + + +def stop_video_if_configured() -> None: + if VIDEO_WORKER: + stop_container(video_container(), timeout=30) + + def stop_music_if_configured() -> None: if MUSIC_WORKER: stop_container(music_container(), timeout=30) @@ -290,6 +308,7 @@ def set_image_worker(running: bool, kind: str = IMAGE_WORKER) -> dict: stop_separator_if_configured() stop_voice_tools() stop_trellis_if_configured() + stop_video_if_configured() for other in image_containers(): if other["Id"] != item["Id"]: stop_container(other, timeout=20) @@ -319,6 +338,7 @@ def set_music_worker(running: bool) -> dict: stop_voice_tools() stop_yue2_if_configured() stop_trellis_if_configured() + stop_video_if_configured() start_container(item) else: stop_container(item, timeout=30) @@ -340,6 +360,7 @@ def set_yue2_worker(running: bool) -> dict: stop_separator_if_configured() stop_voice_tools() stop_trellis_if_configured() + stop_video_if_configured() start_container(item) else: stop_container(item, timeout=30) @@ -361,6 +382,7 @@ def set_separator_worker(running: bool) -> dict: stop_yue2_if_configured() stop_voice_tools() stop_trellis_if_configured() + stop_video_if_configured() start_container(item) else: stop_container(item, timeout=30) @@ -383,6 +405,7 @@ def set_voice_worker(running: bool) -> dict: stop_separator_if_configured() stop_voice_tools("voice") stop_trellis_if_configured() + stop_video_if_configured() start_container(item) else: stop_container(item, timeout=30) @@ -405,6 +428,7 @@ def set_voice_change_worker(running: bool) -> dict: stop_separator_if_configured() stop_voice_tools("voicechange") stop_trellis_if_configured() + stop_video_if_configured() start_container(item) else: stop_container(item, timeout=30) @@ -427,6 +451,7 @@ def set_applio_worker(running: bool) -> dict: stop_separator_if_configured() stop_voice_tools("applio") stop_trellis_if_configured() + stop_video_if_configured() start_container(item) else: stop_container(item, timeout=30) @@ -455,6 +480,28 @@ def set_trellis_worker(running: bool) -> dict: "state": "running" if running else "stopped"} +def set_video_worker(running: bool) -> dict: + """Start LTX-2 exclusively, or stop it before another mode is loaded.""" + with LOCK: + item = video_container() + if running: + for profile_item in containers().values(): + stop_container(profile_item) + for worker in image_containers(): + stop_container(worker, timeout=20) + stop_container(tts_container(), timeout=30) + stop_music_if_configured() + stop_yue2_if_configured() + stop_separator_if_configured() + stop_voice_tools() + stop_trellis_if_configured() + start_container(item) + else: + stop_container(item, timeout=30) + return {"video_worker": VIDEO_WORKER, + "state": "running" if running else "stopped"} + + def active_profile(items: dict[str, dict] | None = None) -> str | None: items = items or containers() active = [name for name, item in items.items() if item.get("State") == "running"] @@ -475,6 +522,7 @@ def activate(profile: str) -> dict: stop_separator_if_configured() stop_voice_tools() stop_trellis_if_configured() + stop_video_if_configured() start_container(tts_container()) items = containers() missing = [name for name in ALLOWED if name not in items] @@ -588,6 +636,13 @@ class Handler(BaseHTTPRequestHandler): "unhealthy" if "(unhealthy)" in trellis_status else "starting" if trellis.get("State") == "running" else "stopped") + video = video_container() if VIDEO_WORKER else {} + video_status = video.get("Status", "") + video_health = ("disabled" if not VIDEO_WORKER else + "healthy" if "(healthy)" in video_status else + "unhealthy" if "(unhealthy)" in video_status else + "starting" if video.get("State") == "running" else + "stopped") self.reply(200, {"active_profile": active_profile(items), "music_worker": music.get("State", "disabled"), "music_health": music_health, @@ -603,6 +658,8 @@ class Handler(BaseHTTPRequestHandler): "applio_health": applio_health, "trellis_worker": trellis.get("State", "disabled"), "trellis_health": trellis_health, + "video_worker": video.get("State", "disabled"), + "video_health": video_health, "profiles": {name: items.get(name, {}).get( "State", "missing") for name in ALLOWED}}) except Exception as exc: @@ -669,6 +726,13 @@ class Handler(BaseHTTPRequestHandler): log.exception("TRELLIS worker transition failed") self.reply(503, {"error": str(exc)}) return + if self.path in {"/workers/video/start", "/workers/video/stop"}: + try: + self.reply(200, set_video_worker(self.path.endswith("/start"))) + except Exception as exc: + log.exception("video worker transition failed") + self.reply(503, {"error": str(exc)}) + return worker_paths = { "/workers/image/start": (IMAGE_WORKER, True), "/workers/image/stop": (IMAGE_WORKER, False), diff --git a/platform/docker/wireguard-gateway/entrypoint.sh b/platform/docker/wireguard-gateway/entrypoint.sh index 34c2043..51d1682 100644 --- a/platform/docker/wireguard-gateway/entrypoint.sh +++ b/platform/docker/wireguard-gateway/entrypoint.sh @@ -86,6 +86,7 @@ start_proxy 8011 applio-studio:6969 start_proxy 8012 mikes-applio-ui:8012 start_proxy 8013 trellis-studio:8080 start_proxy 8014 yue2-studio:8014 +start_proxy 8015 ltx2-studio:8015 start_proxy 8202 mcp-athena-operator:8000 start_proxy 9443 portainer:9443 diff --git a/platform/hermes/skills/athena-operator/references/architecture-and-modes.md b/platform/hermes/skills/athena-operator/references/architecture-and-modes.md index 1f5f714..8095193 100644 --- a/platform/hermes/skills/athena-operator/references/architecture-and-modes.md +++ b/platform/hermes/skills/athena-operator/references/architecture-and-modes.md @@ -18,7 +18,7 @@ correct the durable source when the user requested maintenance. ## Exclusive states -Athena has five mutually exclusive persistent modes: `llm`, `music`, +Athena has mutually exclusive persistent modes: `llm`, `music`, `separation`, `voice`, and `voicechange`. Image generation is a transactional request: it temporarily pauses the active text profile and Qwen3-TTS, runs the image worker, then restores the previous LLM state. @@ -45,3 +45,6 @@ The GPU workers use `restart: "no"` and are created once, then started on demand. A stopped `mike-ai-llama-*`, image, music, separator, OmniVoice or X-VC container is expected. A candidate is stale only after checking Compose, labels, mounts, router/controller references, model paths and test history. + + +`video` starts the allowlisted LTX Desktop worker exclusively. It is controlled with `/athena ltx2` and its private UI is exposed at `http://192.168.1.212:8015`. diff --git a/platform/llama-dashboard/app.py b/platform/llama-dashboard/app.py index 2f5a925..6f8f37d 100644 --- a/platform/llama-dashboard/app.py +++ b/platform/llama-dashboard/app.py @@ -36,6 +36,7 @@ MIKES_APPLIO_UI_URL = os.getenv( ) TRELLIS_UI_URL = os.getenv("TRELLIS_UI_URL", "http://192.168.1.212:8013/") YUE2_UI_URL = os.getenv("YUE2_UI_URL", "http://192.168.1.212:8014/") +LTX2_UI_URL = os.getenv("LTX2_UI_URL", "http://192.168.1.212:8015/") HOST_PROC = Path(os.getenv("HOST_PROC", "/host/proc")) HOST_DATA = os.getenv("HOST_DATA", "/host/data") HOST_MODELS = Path(os.getenv("HOST_MODELS", "/host/models")) @@ -314,7 +315,7 @@ def router_status() -> tuple[dict[str, Any], str | None]: def change_mode(mode: str) -> tuple[int, dict[str, Any]]: if mode not in {"llm", "music", "yue2", "separation", "voice", - "voicechange", "applio", "trellis"}: + "voicechange", "applio", "trellis", "video"}: return 400, {"error": "invalid mode"} headers = {"Accept": "application/json", "Content-Type": "application/json"} if ROUTER_API_KEY: @@ -673,7 +674,7 @@ HTML = r'''
Mike AI · Live Telemetry

Athena llama.cpp Dashboard

verbinde …
- +
Aktives Profil
–
Router wird abgefragt
Modell
–
–
CPU
–
–
@@ -714,8 +715,8 @@ const $=id=>document.getElementById(id); const pct=n=>n==null?'–':`${n.toFixed function gpuCard(g){let total=g.memory_total_mib||0,used=g.memory_used_mib||0,p=total?used/total*100:0,load=Math.max(0,Math.min(100,g.gpu_percent||0));return `
GPU ${g.index}
${g.name}
${g.pstate||'–'}
${pct(g.gpu_percent)}GPU-Kern
${(used/1024).toFixed(1)} / ${(total/1024).toFixed(1)} GiBVRAM
${g.temperature_c??'–'} °CTemperatur
${g.power_w??'–'} / ${g.power_limit_w??'–'} WLeistung
${g.graphics_clock_mhz??'–'} MHzGrafiktakt
${g.memory_clock_mhz??'–'} MHzSpeichertakt
${pct(g.memory_controller_percent)}Memory Controller
${pct(g.fan_percent)}Lüfter
GPU-Auslastung${load.toFixed(1)} %
VRAM-Belegung${p.toFixed(1)} %
${(g.memory_free_mib/1024).toFixed(1)} GiB VRAM frei
`} const imagePhaseLabel=p=>({"stopping-qwen":"Qwen wird entladen","loading-image":"Bildmodell wird geladen","generating":"Bild wird generiert","unloading-image":"Bildmodell wird entladen","restoring-qwen":"Qwen wird wiederhergestellt"}[p]||p||'bereit'); let modeBusy=false; -async function setMode(mode){if(modeBusy)return;modeBusy=true;for(const id of ['llmMode','musicMode','yue2Mode','separationMode','voiceMode','voiceChangeMode','applioMode','trellisMode'])$(id).disabled=true;$('modeStatus').textContent='Umschaltung angefordert …';try{let r=await fetch('/api/mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode})});let d=await r.json();if(!r.ok)throw Error(d?.error?.message||d?.error||`HTTP ${r.status}`);$('modeStatus').textContent='Umschaltung läuft …'}catch(e){$('modeStatus').textContent=e.message;$('modeStatus').classList.add('mode-error')}finally{modeBusy=false;setTimeout(refresh,250)}} -async function refresh(){try{let r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),c=d.cpu||{},m=c.memory||{},rt=d.router||{},up=rt.upstream||{},q=rt.qwen||{},lr=d.llama_runtime||{},img=rt.image||{},imageActive=img.phase&&img.phase!=='idle';let md=rt.mode||{},switchingMode=md.phase&&md.phase!=='ready',modeName=({llm:'LLM-Betrieb',music:'ACE-Step Studio',yue2:'YuE2 Studio',separation:'Stimmtrennung',voice:'Voice Studio',voicechange:'X-VC Voice Changer',applio:'Applio / RVC',trellis:'3D Studio'})[md.active]||'Unbekannt';$('operatingMode').textContent=modeName;$('modeStatus').textContent=switchingMode?`Umschaltung: ${md.phase}`:(md.last_error||`ACE-Step: ${md.music_worker||'–'} · YuE2: ${md.yue2_worker||'–'} · Separator: ${md.separator_worker||'–'} · Voice: ${md.voice_worker||'–'} · 3D: ${md.trellis_worker||'–'}${md.return_profile?` · Rückkehr zu ${md.return_profile}`:''}`);$('modeStatus').classList.toggle('mode-error',!!md.last_error);$('llmMode').classList.toggle('active',md.active==='llm');$('musicMode').classList.toggle('active',md.active==='music');$('yue2Mode').classList.toggle('active',md.active==='yue2');$('separationMode').classList.toggle('active',md.active==='separation');$('voiceMode').classList.toggle('active',md.active==='voice');$('voiceChangeMode').classList.toggle('active',md.active==='voicechange');$('applioMode').classList.toggle('active',md.active==='applio');$('trellisMode').classList.toggle('active',md.active==='trellis');let modeControlsBusy=modeBusy||switchingMode||!md.enabled;for(const id of ['llmMode','musicMode','yue2Mode','separationMode','voiceMode','voiceChangeMode','applioMode','trellisMode'])$(id).disabled=modeControlsBusy;$('musicOpen').hidden=md.active!=='music';$('yue2Open').hidden=md.active!=='yue2';$('separatorOpen').hidden=md.active!=='separation';$('voiceOpen').hidden=md.active!=='voice';$('voiceChangeOpen').hidden=md.active!=='voicechange';$('applioOpen').hidden=md.active!=='applio';$('trellisOpen').hidden=md.active!=='trellis';$('profile').textContent=imageActive?'Bildgenerierung':(rt.current_profile||'nicht geladen');$('profileSub').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching?`Wechsel zu ${rt.switching}`:`Kontext: ${up.ctx?up.ctx.toLocaleString('de-DE'):'–'} Token`);$('model').textContent=imageActive?(img.model||'Bildmodell'):(up.model||'–');$('modelSub').textContent=imageActive?`${img.model_loaded?'geladen':'wird vorbereitet'} · Worker ${img.worker||'–'}`:(lr.model_file|| (up.reachable?'llama.cpp erreichbar':'llama.cpp nicht erreichbar'));$('cpu').textContent=pct(c.usage_percent);$('cpuBar').style.width=`${c.usage_percent||0}%`;$('load').textContent=`${c.logical_cpus||'–'} Threads · Load ${(c.load||[]).join(' / ')}`;let rp=m.total?m.used/m.total*100:0;$('ram').textContent=pct(rp);$('ramBar').style.width=`${rp}%`;$('ramSub').textContent=`${gib(m.used)} / ${gib(m.total)}`;$('gpuCards').innerHTML=(d.gpus||[]).map(gpuCard).join('')||'
Keine GPU-Daten verfügbar
';$('availability').textContent=imageActive?imagePhaseLabel(img.phase):(q.available?'bereit':'nicht bereit');$('availability').className=`value status ${(imageActive||q.available)?'':'bad'}`;$('activeChats').textContent=q.active_chats??'–';$('routerUptime').textContent=dur(rt.uptime_seconds);$('switching').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching||'nein');let disk=c.disk_data||{},dp=disk.total?disk.used/disk.total*100:null;$('dataDisk').textContent=pct(dp);$('processes').innerHTML=(d.gpu_processes||[]).map(p=>`${(d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index??'–'}${p.name}${p.pid}${p.memory_mib??'–'} MiB`).join('')||'Keine Compute-Prozesse gemeldet';let runtime=[['Modell-Datei',lr.model_file],['PID',lr.pid],['Kontext',lr.context_size?lr.context_size.toLocaleString('de-DE'):'–'],['Batch / µBatch',`${lr.batch_size??'–'} / ${lr.ubatch_size??'–'}`],['Parallel',lr.parallel],['Threads',`${lr.threads??'–'} / ${lr.threads_batch??'–'}`],['Geräte',lr.device],['Tensor-Split',lr.tensor_split],['KV-Cache',`${lr.cache_k??'–'} / ${lr.cache_v??'–'}`],['Flash Attention',lr.flash_attention?'an':'aus'],['Prompt-Cache',lr.prompt_cache?'an':'aus'],['MTP Draft',lr.mtp_draft_tokens]];$('runtime').innerHTML=runtime.map(([k,v])=>`
${v??'–'}${k}
`).join('');let es=Object.entries(d.errors||{}).filter(([,v])=>v);$('errors').hidden=!es.length;$('errors').textContent=es.map(([k,v])=>`${k}: ${v}`).join('\n');$('updated').textContent=`Live · ${new Date(d.timestamp*1000).toLocaleTimeString('de-DE')}`;$('dot').style.background='var(--green)'}catch(e){$('updated').textContent=`Verbindung gestört: ${e.message}`;$('dot').style.background='var(--red)'}}refresh();setInterval(refresh,1000); +async function setMode(mode){if(modeBusy)return;modeBusy=true;for(const id of ['llmMode','musicMode','yue2Mode','separationMode','voiceMode','voiceChangeMode','applioMode','trellisMode','videoMode'])$(id).disabled=true;$('modeStatus').textContent='Umschaltung angefordert …';try{let r=await fetch('/api/mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode})});let d=await r.json();if(!r.ok)throw Error(d?.error?.message||d?.error||`HTTP ${r.status}`);$('modeStatus').textContent='Umschaltung läuft …'}catch(e){$('modeStatus').textContent=e.message;$('modeStatus').classList.add('mode-error')}finally{modeBusy=false;setTimeout(refresh,250)}} +async function refresh(){try{let r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),c=d.cpu||{},m=c.memory||{},rt=d.router||{},up=rt.upstream||{},q=rt.qwen||{},lr=d.llama_runtime||{},img=rt.image||{},imageActive=img.phase&&img.phase!=='idle';let md=rt.mode||{},switchingMode=md.phase&&md.phase!=='ready',modeName=({llm:'LLM-Betrieb',music:'ACE-Step Studio',yue2:'YuE2 Studio',separation:'Stimmtrennung',voice:'Voice Studio',voicechange:'X-VC Voice Changer',applio:'Applio / RVC',trellis:'3D Studio',video:'LTX-2 Video Studio'})[md.active]||'Unbekannt';$('operatingMode').textContent=modeName;$('modeStatus').textContent=switchingMode?`Umschaltung: ${md.phase}`:(md.last_error||`ACE-Step: ${md.music_worker||'–'} · YuE2: ${md.yue2_worker||'–'} · Separator: ${md.separator_worker||'–'} · Voice: ${md.voice_worker||'–'} · 3D: ${md.trellis_worker||'–'} · Video: ${md.video_worker||'–'}${md.return_profile?` · Rückkehr zu ${md.return_profile}`:''}`);$('modeStatus').classList.toggle('mode-error',!!md.last_error);$('llmMode').classList.toggle('active',md.active==='llm');$('musicMode').classList.toggle('active',md.active==='music');$('yue2Mode').classList.toggle('active',md.active==='yue2');$('separationMode').classList.toggle('active',md.active==='separation');$('voiceMode').classList.toggle('active',md.active==='voice');$('voiceChangeMode').classList.toggle('active',md.active==='voicechange');$('applioMode').classList.toggle('active',md.active==='applio');$('trellisMode').classList.toggle('active',md.active==='trellis');$('videoMode').classList.toggle('active',md.active==='video');let modeControlsBusy=modeBusy||switchingMode||!md.enabled;for(const id of ['llmMode','musicMode','yue2Mode','separationMode','voiceMode','voiceChangeMode','applioMode','trellisMode','videoMode'])$(id).disabled=modeControlsBusy;$('musicOpen').hidden=md.active!=='music';$('yue2Open').hidden=md.active!=='yue2';$('separatorOpen').hidden=md.active!=='separation';$('voiceOpen').hidden=md.active!=='voice';$('voiceChangeOpen').hidden=md.active!=='voicechange';$('applioOpen').hidden=md.active!=='applio';$('trellisOpen').hidden=md.active!=='trellis';$('videoOpen').hidden=md.active!=='video';$('profile').textContent=imageActive?'Bildgenerierung':(rt.current_profile||'nicht geladen');$('profileSub').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching?`Wechsel zu ${rt.switching}`:`Kontext: ${up.ctx?up.ctx.toLocaleString('de-DE'):'–'} Token`);$('model').textContent=imageActive?(img.model||'Bildmodell'):(up.model||'–');$('modelSub').textContent=imageActive?`${img.model_loaded?'geladen':'wird vorbereitet'} · Worker ${img.worker||'–'}`:(lr.model_file|| (up.reachable?'llama.cpp erreichbar':'llama.cpp nicht erreichbar'));$('cpu').textContent=pct(c.usage_percent);$('cpuBar').style.width=`${c.usage_percent||0}%`;$('load').textContent=`${c.logical_cpus||'–'} Threads · Load ${(c.load||[]).join(' / ')}`;let rp=m.total?m.used/m.total*100:0;$('ram').textContent=pct(rp);$('ramBar').style.width=`${rp}%`;$('ramSub').textContent=`${gib(m.used)} / ${gib(m.total)}`;$('gpuCards').innerHTML=(d.gpus||[]).map(gpuCard).join('')||'
Keine GPU-Daten verfügbar
';$('availability').textContent=imageActive?imagePhaseLabel(img.phase):(q.available?'bereit':'nicht bereit');$('availability').className=`value status ${(imageActive||q.available)?'':'bad'}`;$('activeChats').textContent=q.active_chats??'–';$('routerUptime').textContent=dur(rt.uptime_seconds);$('switching').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching||'nein');let disk=c.disk_data||{},dp=disk.total?disk.used/disk.total*100:null;$('dataDisk').textContent=pct(dp);$('processes').innerHTML=(d.gpu_processes||[]).map(p=>`${(d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index??'–'}${p.name}${p.pid}${p.memory_mib??'–'} MiB`).join('')||'Keine Compute-Prozesse gemeldet';let runtime=[['Modell-Datei',lr.model_file],['PID',lr.pid],['Kontext',lr.context_size?lr.context_size.toLocaleString('de-DE'):'–'],['Batch / µBatch',`${lr.batch_size??'–'} / ${lr.ubatch_size??'–'}`],['Parallel',lr.parallel],['Threads',`${lr.threads??'–'} / ${lr.threads_batch??'–'}`],['Geräte',lr.device],['Tensor-Split',lr.tensor_split],['KV-Cache',`${lr.cache_k??'–'} / ${lr.cache_v??'–'}`],['Flash Attention',lr.flash_attention?'an':'aus'],['Prompt-Cache',lr.prompt_cache?'an':'aus'],['MTP Draft',lr.mtp_draft_tokens]];$('runtime').innerHTML=runtime.map(([k,v])=>`
${v??'–'}${k}
`).join('');let es=Object.entries(d.errors||{}).filter(([,v])=>v);$('errors').hidden=!es.length;$('errors').textContent=es.map(([k,v])=>`${k}: ${v}`).join('\n');$('updated').textContent=`Live · ${new Date(d.timestamp*1000).toLocaleTimeString('de-DE')}`;$('dot').style.background='var(--green)'}catch(e){$('updated').textContent=`Verbindung gestört: ${e.message}`;$('dot').style.background='var(--red)'}}refresh();setInterval(refresh,1000); async function refreshBackups(){try{let r=await fetch('/api/backups',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),rows=d.backups||[];$('backupFiles').innerHTML=rows.map(b=>`${new Date(b.modified*1000).toLocaleString('de-DE')}${gib(b.size)}${b.sha256||'Prüfsumme fehlt'}Herunterladen`).join('')||'Noch kein portables Backup vorhanden';$('backupNote').textContent=rows.length?`${rows.length} von maximal 5 Generationen · verschlüsselt mit Age`:'Der erste Lauf startet spätestens fünf Stunden nach Aktivierung.'}catch(e){$('backupNote').textContent=`Backup-Liste nicht verfügbar: ${e.message}`}}refreshBackups();setInterval(refreshBackups,60000); '''.replace( "__MUSIC_ORIGINAL_UI_URL__", MUSIC_ORIGINAL_UI_URL @@ -726,7 +727,8 @@ async function refreshBackups(){try{let r=await fetch('/api/backups',{cache:'no- ).replace("__APPLIO_UI_URL__", APPLIO_UI_URL ).replace("__MIKES_APPLIO_UI_URL__", MIKES_APPLIO_UI_URL).replace( "__TRELLIS_UI_URL__", TRELLIS_UI_URL -).replace("__YUE2_UI_URL__", YUE2_UI_URL) +).replace("__YUE2_UI_URL__", YUE2_UI_URL).replace( + "__LTX2_UI_URL__", LTX2_UI_URL) FULL_JS = r''' diff --git a/platform/ltx2-studio/Dockerfile b/platform/ltx2-studio/Dockerfile new file mode 100644 index 0000000..c303acb --- /dev/null +++ b/platform/ltx2-studio/Dockerfile @@ -0,0 +1,34 @@ +FROM ubuntu:24.04 + +ARG LTX_DESKTOP_VERSION=1.2.7 +ARG LTX_DESKTOP_SHA512=d1d59027988a48490492feb42156665bbed511f187739a664ad326492fd8fc0ce43429537150eb6aea9a75a522f6353a40839f9b3ed0449fb720b7c13b091706 + +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates curl dbus-x11 ffmpeg libasound2t64 libatk-bridge2.0-0 \ + libatk1.0-0 libcups2 libdrm2 libgbm1 libgtk-3-0 libnss3 libx11-xcb1 \ + libxcomposite1 libxdamage1 libxfixes3 libxkbcommon0 libxrandr2 \ + novnc openbox procps python3-websockify x11vnc xvfb \ + && rm -rf /var/lib/apt/lists/* \ + && install -d /opt/ltx-desktop \ + && curl -fL --retry 5 \ + "https://github.com/Lightricks/LTX-Desktop/releases/download/v${LTX_DESKTOP_VERSION}/LTX-Desktop-x86_64.AppImage" \ + -o /tmp/ltx-desktop.AppImage \ + && printf '%s %s\n' "$LTX_DESKTOP_SHA512" /tmp/ltx-desktop.AppImage | sha512sum -c - \ + && chmod +x /tmp/ltx-desktop.AppImage \ + && cd /opt/ltx-desktop \ + && /tmp/ltx-desktop.AppImage --appimage-extract >/dev/null \ + && rm /tmp/ltx-desktop.AppImage \ + && ln -s /usr/share/novnc/vnc.html /usr/share/novnc/index.html + +COPY entrypoint.sh /usr/local/bin/ltx-desktop-entrypoint +RUN chmod 0755 /usr/local/bin/ltx-desktop-entrypoint + +ENV DISPLAY=:0 \ + HOME=/data/home \ + XDG_DATA_HOME=/data \ + XDG_CONFIG_HOME=/data/config \ + XDG_CACHE_HOME=/data/cache \ + NO_AT_BRIDGE=1 + +EXPOSE 8015 +ENTRYPOINT ["/usr/local/bin/ltx-desktop-entrypoint"] diff --git a/platform/ltx2-studio/README.md b/platform/ltx2-studio/README.md new file mode 100644 index 0000000..aa8a3d1 --- /dev/null +++ b/platform/ltx2-studio/README.md @@ -0,0 +1,16 @@ +# LTX-2 Studio + +This specialist profile runs the official LTX Desktop 1.2.7 application in a +browser-accessible private desktop. The image is pinned to the release checksum. +Application data, downloaded models and outputs persist below +`/data/video/ltx-desktop`. + +The profile controller starts only the container labelled +`com.mike-ai.video-worker=ltx2`. Starting it stops every LLM, image, speech, +music, voice and 3D GPU worker first. The UI is reachable only through Athena's +WireGuard gateway at `http://192.168.1.212:8015`. + +The RTX 5080 has the official minimum of 16 GiB VRAM for LTX Desktop local +generation. Start with LTX Fast, 720p or below and at most about ten seconds. +Model downloads may require accepting Lightricks' model license and signing in +to Hugging Face in the application. diff --git a/platform/ltx2-studio/compose.yaml b/platform/ltx2-studio/compose.yaml new file mode 100644 index 0000000..3a57003 --- /dev/null +++ b/platform/ltx2-studio/compose.yaml @@ -0,0 +1,35 @@ +services: + ltx2-studio: + build: + context: . + args: + LTX_DESKTOP_VERSION: "1.2.7" + LTX_DESKTOP_SHA512: "d1d59027988a48490492feb42156665bbed511f187739a664ad326492fd8fc0ce43429537150eb6aea9a75a522f6353a40839f9b3ed0449fb720b7c13b091706" + image: mike-ai/ltx2-studio:1.2.7 + container_name: mike-ai-ltx2-studio + restart: "no" + labels: + com.mike-ai.video-worker: ltx2 + gpus: all + shm_size: 8g + environment: + NVIDIA_VISIBLE_DEVICES: ${LTX2_GPU_UUID:-GPU-8ad38c6c-5a01-9d8e-1dfa-ed662ad78fbe} + NVIDIA_DRIVER_CAPABILITIES: compute,utility,graphics + volumes: + - /data/video/ltx-desktop:/data + networks: + - frontend + security_opt: + - no-new-privileges:true + cap_drop: [ALL] + healthcheck: + test: [CMD, curl, -fsS, http://127.0.0.1:8015/] + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + +networks: + frontend: + external: true + name: mike-ai_frontend diff --git a/platform/ltx2-studio/entrypoint.sh b/platform/ltx2-studio/entrypoint.sh new file mode 100644 index 0000000..68d2a57 --- /dev/null +++ b/platform/ltx2-studio/entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -eu + +install -d -m 0755 "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" /data/models /data/outputs + +cleanup() { + kill "${app_pid:-}" "${web_pid:-}" "${vnc_pid:-}" "${wm_pid:-}" "${x_pid:-}" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +Xvfb :0 -screen 0 1600x1000x24 -nolisten tcp & +x_pid=$! +for _ in $(seq 1 50); do + [ -S /tmp/.X11-unix/X0 ] && break + sleep 0.1 +done +openbox >/tmp/openbox.log 2>&1 & +wm_pid=$! +x11vnc -display :0 -forever -shared -nopw -rfbport 5900 \ + >/tmp/x11vnc.log 2>&1 & +vnc_pid=$! +websockify --web=/usr/share/novnc 8015 127.0.0.1:5900 \ + >/tmp/websockify.log 2>&1 & +web_pid=$! + +/opt/ltx-desktop/squashfs-root/AppRun --no-sandbox --disable-gpu-sandbox \ + --disable-dev-shm-usage >/data/ltx-desktop.log 2>&1 & +app_pid=$! + +wait "$app_pid" diff --git a/platform/recovery/rebuild-specialized.sh b/platform/recovery/rebuild-specialized.sh index d69aad7..a2bc2e2 100755 --- a/platform/recovery/rebuild-specialized.sh +++ b/platform/recovery/rebuild-specialized.sh @@ -14,6 +14,7 @@ fi export VOICE_GPU_UUID=${VOICE_GPU_UUID:-${IMAGE_GPU_DEVICES:-}} export ACESTEP_GPU_UUID=${ACESTEP_GPU_UUID:-${IMAGE_GPU_DEVICES:-}} export SEPARATOR_GPU_UUID=${SEPARATOR_GPU_UUID:-${IMAGE_GPU_DEVICES:-}} +export LTX2_GPU_UUID=${LTX2_GPU_UUID:-${IMAGE_GPU_DEVICES:-}} create_project() { local dir=$1 file=${2:-compose.yaml} profile=${3:-} @@ -32,5 +33,6 @@ create_project /opt/mike-ai/omnivoice-studio create_project /opt/mike-ai/xvc-studio create_project /opt/mike-ai/stack/experiments/applio-rvc create_project /opt/mike-ai/Mikes-Applio-UI compose.example.yaml +create_project /opt/mike-ai/ltx2-studio printf 'ATHENA_SPECIALISTS_REBUILT_OK\n' diff --git a/router/ai_profile_router.py b/router/ai_profile_router.py index 8b087f7..e6a3e55 100755 --- a/router/ai_profile_router.py +++ b/router/ai_profile_router.py @@ -114,6 +114,7 @@ VOICE_START_TIMEOUT = float(os.environ.get("VOICE_START_TIMEOUT", "600")) VOICE_CHANGE_START_TIMEOUT = float(os.environ.get("VOICE_CHANGE_START_TIMEOUT", "600")) APPLIO_START_TIMEOUT = float(os.environ.get("APPLIO_START_TIMEOUT", "900")) TRELLIS_START_TIMEOUT = float(os.environ.get("TRELLIS_START_TIMEOUT", "900")) +VIDEO_START_TIMEOUT = float(os.environ.get("VIDEO_START_TIMEOUT", "900")) # Optional worker APIs. The clean Docker baseline deliberately ships only # text/multimodal chat; absent workers must fail explicitly instead of trying @@ -502,6 +503,11 @@ def _wait_trellis_ready() -> None: "TRELLIS.2", TRELLIS_START_TIMEOUT) +def _wait_video_ready() -> None: + _wait_aux_voice_ready("video_worker", "video_health", + "LTX-2 Studio", VIDEO_START_TIMEOUT) + + def _special_worker(mode: str) -> tuple[str, str, callable]: if mode == "music": return "/workers/music/start", _music_worker_state(), _wait_music_ready @@ -521,6 +527,9 @@ def _special_worker(mode: str) -> tuple[str, str, callable]: if mode == "trellis": return ("/workers/trellis/start", _worker_field("trellis_worker"), _wait_trellis_ready) + if mode == "video": + return ("/workers/video/start", _worker_field("video_worker"), + _wait_video_ready) raise ValueError(f"unbekannter Spezialmodus: {mode}") @@ -529,7 +538,7 @@ def set_operating_mode(mode: str) -> dict: if not ENABLE_MUSIC_MODE or not PROFILE_CONTROL_URL: raise RuntimeError("Musikmodus ist nicht konfiguriert") special_modes = {"music", "yue2", "separation", "voice", "voicechange", - "applio", "trellis"} + "applio", "trellis", "video"} if mode not in {"llm", *special_modes}: raise ValueError("unbekannter Betriebsmodus") with STATE.lock: @@ -582,6 +591,7 @@ def set_operating_mode(mode: str) -> dict: _profile_controller_request("POST", "/workers/voice-change/stop") _profile_controller_request("POST", "/workers/applio/stop") _profile_controller_request("POST", "/workers/trellis/stop") + _profile_controller_request("POST", "/workers/video/stop") _restore_qwen(profile) STATE.mode = "llm" STATE.mode_phase = "ready" @@ -601,7 +611,7 @@ def schedule_operating_mode(mode: str) -> tuple[bool, str]: if not ENABLE_MUSIC_MODE or not PROFILE_CONTROL_URL: raise RuntimeError("Musikmodus ist nicht konfiguriert") if mode not in {"llm", "music", "yue2", "separation", "voice", - "voicechange", "applio", "trellis"}: + "voicechange", "applio", "trellis", "video"}: raise ValueError("unbekannter Betriebsmodus") with STATE.lock: if STATE.mode_phase not in {"ready", "error"}: @@ -643,7 +653,8 @@ def _control_command(data: dict, path: str) -> str | None: "/athena voicechange", "/athena changer", "/athena applio", "/athena 3d", "/athena trellis", - "/athena status"} else None + "/athena video", "/athena ltx", + "/athena ltx2", "/athena status"} else None # --------------------------------------------------------------------------- @@ -2129,6 +2140,8 @@ class Handler(BaseHTTPRequestHandler): "applio_health": _worker_field("applio_health"), "trellis_worker": _worker_field("trellis_worker"), "trellis_health": _worker_field("trellis_health"), + "video_worker": _worker_field("video_worker"), + "video_health": _worker_field("video_health"), "return_profile": state.get("return_profile"), "last_error": STATE.mode_error, "enabled": ENABLE_MUSIC_MODE, @@ -2139,7 +2152,7 @@ class Handler(BaseHTTPRequestHandler): data = json.loads(self._read_body() or b"{}") mode = data.get("mode") if isinstance(data, dict) else None if mode not in {"llm", "music", "yue2", "separation", "voice", - "voicechange", "applio", "trellis"}: + "voicechange", "applio", "trellis", "video"}: raise ValueError("Feld 'mode' enthält einen unbekannten Betriebsmodus") started, phase = schedule_operating_mode(mode) self._send_json(202 if started else 200, { @@ -2807,7 +2820,8 @@ class Handler(BaseHTTPRequestHandler): f"{mode['separator_worker']}. Voice Studio: " f"{mode['voice_worker']}. Voice Changer: " f"{mode['voice_change_worker']}. 3D Studio: " - f"{mode['trellis_worker']}. LLM-Profil: {profile or 'entladen'}.") + f"{mode['trellis_worker']}. LTX-2 Studio: " + f"{mode['video_worker']}. LLM-Profil: {profile or 'entladen'}.") else: target = ("music" if command == "/athena music" else "yue2" if command == "/athena yue2" else @@ -2816,6 +2830,7 @@ class Handler(BaseHTTPRequestHandler): else "voicechange" if command in {"/athena voicechange", "/athena changer"} else "applio" if command == "/athena applio" else "trellis" if command in {"/athena 3d", "/athena trellis"} + else "video" if command in {"/athena video", "/athena ltx", "/athena ltx2"} else "llm") try: started, phase = schedule_operating_mode(target) @@ -3128,7 +3143,7 @@ def _startup_reconcile() -> None: special_mode = previous.get("mode") if ENABLE_MUSIC_MODE and special_mode in {"music", "yue2", "separation", "voice", "voicechange", "applio", - "trellis"}: + "trellis", "video"}: STATE.mode = special_mode STATE.mode_phase = f"starting-{special_mode}" _set_qwen_unavailable(True)