Show video transition and generation progress in separate views

This commit is contained in:
Mikei386
2026-09-29 13:46:05 +02:00
parent e851b2f3ea
commit 0ec51bcf6d
5 changed files with 78 additions and 21 deletions
+3 -1
View File
@@ -168,10 +168,12 @@ Eigene Video-API auf demselben API-Port und mit demselben Bearer-Token wie Chat
- `GET /v1/videos/{id}`: Zustand/Phase und Fehler des letzten Auftrags, ohne Prompt. - `GET /v1/videos/{id}`: Zustand/Phase und Fehler des letzten Auftrags, ohne Prompt.
- `GET /v1/videos/{id}/content`: fertige MP4 einschließlich Audiospur. - `GET /v1/videos/{id}/content`: fertige MP4 einschließlich Audiospur.
Anfragewerte überschreiben Profilstandards. Breite/Höhe sind Vielfache von 64, 256–1920 bzw. 256–1088; Bildanzahl 9–241 in der Form 8n+1, FPS 1–60. Distilled hat eine feste Schrittfolge; das bisherige allgemeine Schritte-Profilfeld wird von diesem Adapter nicht verwendet. Resultate liegen im Deck-Zustandsverzeichnis `video/`; der erste Adapter bietet jeweils den letzten Auftrag an. Kein Prompt wird auf Platte geschrieben. Eigener Video-Test mit Vorschau/Download unter Laufzeiten → Video. Bild-/Audio-Upload, automatische Video-Tool-Aufträge aus Hermes, Abfragehistorie und automatische Ergebnisbereinigung sind noch nicht umgesetzt. Anfragewerte überschreiben Profilstandards. Breite/Höhe sind Vielfache von 64, 256–1920 bzw. 256–1088; Bildanzahl 9–241 in der Form 8n+1, FPS 1–60. Distilled hat eine feste Schrittfolge; das bisherige allgemeine Schritte-Profilfeld wird von diesem Adapter nicht verwendet. Resultate liegen im Deck-Zustandsverzeichnis `video/`; der erste Adapter bietet jeweils den letzten Auftrag an. Kein Prompt wird auf Platte geschrieben. Eigener Video-Test mit Vorschau/Download unter Video → Testen. Bild-/Audio-Upload, automatische Video-Tool-Aufträge aus Hermes, Abfragehistorie und automatische Ergebnisbereinigung sind noch nicht umgesetzt.
Während Video/Moduswechsel erhalten neue Chat-, Bild- und TTS-API-Aufträge HTTP 503 mit `video_mode_active`. CPU-STT bleibt verfügbar. Ein Wechsel wird nur über die angemeldete Verwaltungsoberfläche ausgelöst; API-Clients dürfen den Modus nicht heimlich zurückschalten. Die interne Verwaltungs-API verwendet `GET /api/v1/video`, `POST /api/v1/video/profile` mit `{id}`, `POST /api/v1/video/mode` mit `{mode:"llm"|"video"}`, `/api/v1/video/generate` und `/api/v1/video-runtime/install|cancel`; jeweils bestehender Sitzungsschutz und POST-Header `X-Athena-Deck: 1`. Während Video/Moduswechsel erhalten neue Chat-, Bild- und TTS-API-Aufträge HTTP 503 mit `video_mode_active`. CPU-STT bleibt verfügbar. Ein Wechsel wird nur über die angemeldete Verwaltungsoberfläche ausgelöst; API-Clients dürfen den Modus nicht heimlich zurückschalten. Die interne Verwaltungs-API verwendet `GET /api/v1/video`, `POST /api/v1/video/profile` mit `{id}`, `POST /api/v1/video/mode` mit `{mode:"llm"|"video"}`, `/api/v1/video/generate` und `/api/v1/video-runtime/install|cancel`; jeweils bestehender Sitzungsschutz und POST-Header `X-Athena-Deck: 1`.
Video-Build-Voraussetzung: Python-Entwicklungsheader passend zur verwendeten Python-Version sowie ein C-Compiler (Debian: `python3-dev`, `build-essential`). Das Docker-Installationspaket bringt diese mit. Triton kompiliert seinen CUDA-Helfer beim ersten Auftrag; dafür werden keine Host-Treiber installiert. Video-Build-Voraussetzung: Python-Entwicklungsheader passend zur verwendeten Python-Version sowie ein C-Compiler (Debian: `python3-dev`, `build-essential`). Das Docker-Installationspaket bringt diese mit. Triton kompiliert seinen CUDA-Helfer beim ersten Auftrag; dafür werden keine Host-Treiber installiert.
Verifiziert auf Athena am 29.09.2026: offizielles LTX-2.5-Distilled-BF16-Profil mit zugeordneten Komponenten vorbereitet; exklusiver Modus sperrt Chat mit HTTP 503 / `video_mode_active`; synthetischer POST-Videoauftrag liefert HTTP 202, Statusabfrage und MP4-Download funktionieren. Ergebnis: 256×256, 9 dekodierbare Bilder, 24 FPS und Audiospur (12.802 Bytes). Nach Rückwechsel auf LLM war der Video-Prozess beendet und der GPU-Speicher wieder auf Treibergrundbelegung (3060: 1 MiB, 5080: 6 MiB). Der anfängliche Triton-Kompilierfehler wurde durch Python-Entwicklungsheader im Deck-Image behoben. Kein Produktivdienst wurde für diese Prüfung verändert. Dieser kleine Funktionstest ist keine Speicher- oder Geschwindigkeitsgarantie für größere Auflösungen/Längen. Verifiziert auf Athena am 29.09.2026: offizielles LTX-2.5-Distilled-BF16-Profil mit zugeordneten Komponenten vorbereitet; exklusiver Modus sperrt Chat mit HTTP 503 / `video_mode_active`; synthetischer POST-Videoauftrag liefert HTTP 202, Statusabfrage und MP4-Download funktionieren. Ergebnis: 256×256, 9 dekodierbare Bilder, 24 FPS und Audiospur (12.802 Bytes). Nach Rückwechsel auf LLM war der Video-Prozess beendet und der GPU-Speicher wieder auf Treibergrundbelegung (3060: 1 MiB, 5080: 6 MiB). Der anfängliche Triton-Kompilierfehler wurde durch Python-Entwicklungsheader im Deck-Image behoben. Kein Produktivdienst wurde für diese Prüfung verändert. Dieser kleine Funktionstest ist keine Speicher- oder Geschwindigkeitsgarantie für größere Auflösungen/Längen.
Video-Oberfläche: Moduswechsel erfolgen ausschließlich in der Übersicht. Eine laufende Ladeanzeige zeigt Phase und verstrichene Zeit. Video → Testen meldet einen falschen Modus mit Link zur Übersicht und bestätigt das Absenden sofort. Video → Laufend zeigt ausschließlich Worker, aktuellen Auftrag und Ergebnis. Installationsaktionen liegen unter Einstellungen → Laufzeiten → Video. Die Auftragsanzeige wird alle 1,5 Sekunden aktualisiert; der Worker liefert Phasen, jedoch keine belastbaren Prozentwerte.
+1 -1
View File
@@ -7,7 +7,7 @@ const Studio=(()=>{
if(!force&&document.querySelector('#studio')?.dataset.page===page)return; if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
if(category!==page){category=page;section='discover';} if(category!==page){category=page;section='discover';}
if(modelId)section='profiles'; if(modelId)section='profiles';
const body=page==='stt-runtime'?STTUI.html(true):page==='tts-runtime'?TTSUI.html(true):['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}</p>${audioDescriptions[page]&&!['audio','stt'].includes(page)?'<p class="note">Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.</p>':''}<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(['audio','stt'].includes(page)?[['setup','Einrichten']]:[]),...(['image','chat','audio','stt','video'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[]),['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${['test','running'].includes(section)&&page==='video'?VideoUI.html():section==='setup'?(page==='stt'?STTUI.html(true):TTSUI.html(true)):['test','running'].includes(section)&&page==='stt'?STTUI.html():['test','running'].includes(section)&&page==='audio'?TTSUI.html():section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`; const body=page==='stt-runtime'?STTUI.html(true):page==='tts-runtime'?TTSUI.html(true):['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}</p>${audioDescriptions[page]&&!['audio','stt'].includes(page)?'<p class="note">Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.</p>':''}<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(['audio','stt'].includes(page)?[['setup','Einrichten']]:[]),...(['image','chat','audio','stt','video'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[]),['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${['test','running'].includes(section)&&page==='video'?VideoUI.html(section==='running'?'running':'test'):section==='setup'?(page==='stt'?STTUI.html(true):TTSUI.html(true)):['test','running'].includes(section)&&page==='stt'?STTUI.html():['test','running'].includes(section)&&page==='audio'?TTSUI.html():section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`; document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;} if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;}
if(page==='stt-runtime'){STTUI.bind(true);return;} if(page==='stt-runtime'){STTUI.bind(true);return;}
+4
View File
@@ -22,3 +22,7 @@ nav a.nav-sub{padding-left:28px;font-size:12px;color:#a4bda8}.component-card{mar
.chat-text,#chat-transcript pre{white-space:pre-wrap;overflow-wrap:anywhere;} .chat-text,#chat-transcript pre{white-space:pre-wrap;overflow-wrap:anywhere;}
.endpoint-choice{display:flex;align-items:center;gap:.6rem}.endpoint-choice input{width:auto;margin:0} .endpoint-choice{display:flex;align-items:center;gap:.6rem}.endpoint-choice input{width:auto;margin:0}
.video-activity,.video-notice{padding:18px;border:1px solid #536a51;border-radius:10px;margin:16px 0;background:#202c25}.video-activity strong{display:block}.video-activity progress{display:block;width:100%;height:12px;margin:14px 0;accent-color:#c5edaf}.video-error{color:#ffd5bf;border-left:3px solid #d99b73;padding-left:14px}.video-test-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:16px}#video-test textarea{width:100%;box-sizing:border-box;background:#101718;color:#e4eee5;border:1px solid #465348;border-radius:8px;padding:14px;font:inherit;resize:vertical}.generated-video{display:block;max-width:100%;margin:18px 0;border-radius:8px}
.video-activity progress:indeterminate{background:linear-gradient(90deg,#303a37 0%,#c5edaf 50%,#303a37 100%);background-size:200% 100%;animation:video-progress 1.8s linear infinite;border:0;border-radius:8px}.video-activity progress:indeterminate::-webkit-progress-bar{background:transparent}@keyframes video-progress{from{background-position:200% 0}to{background-position:0 0}}@media(prefers-reduced-motion:reduce){.video-activity progress:indeterminate{animation:none}}
+62 -15
View File
@@ -1,27 +1,74 @@
window.VideoUI=(()=>{ window.VideoUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const labels={llm:'LLM',video:'Video',switching:'Wechsel läuft',idle:'Bereit für LLM',ready:'Videoprofil bereit',failed:'Fehlgeschlagen',running:'Video wird erzeugt',complete:'Video fertig'};
const elapsed=t=>{const seconds=Math.max(0,Math.floor(Date.now()/1000-t));return `${Math.floor(seconds/60)} Min. ${seconds%60} Sek.`;};
const busy=(title,detail='')=>`<div class="video-activity" role="status" aria-live="polite" aria-busy="true"><strong>${e(title)}</strong><progress aria-label="Verarbeitung läuft"></progress><p>${e(detail)}</p></div>`;
async function api(path,data){const r=await fetch('/api/v1/'+path,data!==undefined?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{}),s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;} async function api(path,data){const r=await fetch('/api/v1/'+path,data!==undefined?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{}),s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;}
function workerState(s){
if(s.state==='switching')return busy(`Wechsel zu ${labels[s.target_mode]||'anderem Modus'}`,`${s.switch_phase||'Laufende Aufträge beenden und Speicher freigeben'}${s.switch_started_at?' · '+elapsed(s.switch_started_at):''}`);
return `<div class="video-notice ${s.error?'video-error':''}" role="status"><strong>${e(labels[s.state]||s.state)}</strong><p>GPU-Modus: ${e(labels[s.mode]||s.mode)}${s.profile_name?' · Geladen: '+e(s.profile_name):''}</p>${s.error?`<p role="alert">${e(s.error)}</p>`:''}</div>`;
}
function jobState(job){
if(!job)return '<p>Noch kein Video-Test gestartet.</p>';
if(job.state==='running')return busy('Video-Test läuft',`${job.phase||'Auftrag wird vorbereitet'} · Laufzeit ${elapsed(job.created_at)}. BF16 mit Disk-Streaming kann mehrere Minuten benötigen. Eine verlässliche Prozentanzeige liefert der Worker noch nicht.`);
return `<div class="video-notice ${job.state==='failed'?'video-error':''}" role="status"><strong>${e(labels[job.state]||job.state)}</strong>${job.state==='complete'?'<p>Das Ergebnis steht unten zur Vorschau und zum Download bereit.</p>':''}${job.error?`<p role="alert">${e(job.error)}</p>`:''}</div>`;
}
function mode(root,s,profiles){ function mode(root,s,profiles){
if(root.dataset.busy==='1')return; if(root.dataset.busy==='1')return;
const html=`<section class="card"><h2>GPU-Modus</h2><div class="card-actions"><button data-mode="llm" ${s.mode==='llm'&&s.state!=='failed'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${s.mode==='video'&&s.state==='ready'||s.state==='switching'?'disabled':''}>Video</button></div><p>Modus: <strong>${e(s.mode.toUpperCase())}</strong> · ${e(s.state)} · ${e(s.error||'')}</p><p>Freigegebenes Videoprofil: <strong>${e(profiles.find(p=>p.id===s.profile_id)?.name||'Keines')}</strong> · <a class="link" href="#video">Unter Video → Profile freigeben</a></p><p>${e(s.memory_policy)}</p><p>Video beendet laufende Deck-GPU-Aufträge und sperrt Chat, Bild und TTS (HTTP 503). CPU-STT bleibt verfügbar. Fremde Dienste werden nicht beendet.</p><p><a class="link" href="#video-runtime">Video-Laufzeit und Testauftrag →</a></p><p class="video-message" role="status"></p></section>`; const html=`<section class="card"><h2>GPU-Modus</h2><div class="card-actions"><button data-mode="llm" ${s.mode==='llm'&&s.state!=='failed'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${s.mode==='video'&&s.state==='ready'||s.state==='switching'?'disabled':''}>Video</button></div>${workerState(s)}<p>Freigegebenes Videoprofil: <strong>${e(profiles.find(p=>p.id===s.profile_id)?.name||'Keines')}</strong> · <a class="link" href="#video">Unter Video → Profile freigeben</a></p><p>${e(s.memory_policy)}</p><p>Video beendet laufende Deck-GPU-Aufträge und sperrt Chat, Bild und TTS. CPU-STT bleibt verfügbar.</p><div class="video-message" role="status"></div></section>`;
if(root._html===html)return;root._html=html;root.innerHTML=html; if(root._html===html)return;root._html=html;root.innerHTML=html;
async function act(path,data){root.dataset.busy='1';try{await api(path,data);root._html='';root.querySelector('.video-message').textContent='Übernommen; Status wird aktualisiert.';}catch(x){root.querySelector('.video-message').textContent=x.message;}finally{root.dataset.busy='0';}} root.querySelectorAll('[data-mode]').forEach(b=>b.onclick=async()=>{
root.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>act('video/mode',{mode:b.dataset.mode})); root.dataset.busy='1';root.querySelectorAll('button').forEach(x=>x.disabled=true);
root.querySelector('.video-message').innerHTML=busy('Moduswechsel wird angefordert …');
let next;
try{next=await api('video/mode',{mode:b.dataset.mode});}catch(x){root.querySelector('.video-message').textContent=x.message;root.querySelectorAll('button').forEach(x=>x.disabled=false);}
finally{root.dataset.busy='0';root._html='';}
if(next&&root.isConnected)mode(root,next,profiles);
});
} }
function html(testing=false){return `<section id="video-runtime-page">${testing?'<h2>Video testen</h2><p>Unter Video → Profile ein Profil freigeben, dann hier den Video-Modus aktivieren und einen kurzen Test starten.</p><div id="video-test-mode"></div>':'<h1>LTX-Video-Laufzeit</h1>'}<div ${testing?'hidden':''}><p>Eigene Python-Umgebung, gepinnte LTX-Pipelines, CUDA 12.8. Installation verändert keine Host-Treiber. Erster Ausführungsweg: Text → Video mit dem Distilled-BF16-Rezept und Disk-Streaming auf der RTX 5080.</p><p id="video-status"></p><button id="video-install">Laufzeit installieren</button><button id="video-cancel">Installation abbrechen</button><p><a href="#home">Modus und aktives Profil in der Übersicht auswählen →</a></p></div><p id="video-message" role="status"></p><section class="card"><h2>Video-Test</h2><p>Zuerst den Video-Modus aktivieren. Werte überschreiben die Profilstandards für diesen Auftrag. Distilled nutzt seine feste Schrittfolge. Noch kein Bild-/Audio-Upload.</p><form id="video-test"><label>Prompt<textarea name="prompt" required maxlength="8000"></textarea></label>${[['width','Breite',256],['height','Höhe',256],['frames','Bilder (8n+1)',9],['fps','FPS',24],['seed','Seed',42]].map(([n,l,v])=>`<label>${l}<input type="number" name="${n}" value="${v}" required></label>`).join('')}<button>Video erzeugen</button></form><p id="video-job"></p><div id="video-result"></div><p>API: POST /v1/videos · GET /v1/videos/{id} · GET /v1/videos/{id}/content. Gleicher Port und API-Token wie Chat; eigenständige Deck-Video-API.</p></section></section>`;} function html(view='test'){
async function runtime(){ const test=view==='test',runtime=view==='runtime';
const view=document.querySelector('#view');if(view.querySelector('#video-runtime-page'))return; return `<section id="video-runtime-page" data-video-view="${view}"><h2>${runtime?'LTX-Video-Laufzeit':test?'Video testen':'Video · Laufend'}</h2>${runtime?'<p>Eigene LTX-Python-Umgebung mit CUDA und Disk-Streaming auf der RTX 5080.</p><div id="video-install-status"></div><button id="video-install">Laufzeit installieren</button><button id="video-cancel">Installation abbrechen</button>':'<div id="video-worker-state"></div>'}<div id="video-message" role="status" aria-live="polite"></div>${test?`<section class="card"><p>Kurzer Text-zu-Video-Test mit dem freigegebenen Profil. Werte gelten nur für diesen Auftrag. Noch kein Bild-/Audio-Upload.</p><p id="video-mode-hint"></p><form id="video-test"><label>Prompt<textarea name="prompt" required maxlength="8000" rows="4" placeholder="Zum Beispiel: Ein roter Ball auf einem Holztisch, ruhige Kamera."></textarea></label><div class="video-test-fields">${[['width','Breite',256,256,1920,64],['height','Höhe',256,256,1088,64],['frames','Bilder (8n+1)',9,9,241,8],['fps','FPS',24,1,60,1],['seed','Seed',42,-1,2147483647,1]].map(([n,l,v,min,max,step])=>`<label>${l}<input type="number" name="${n}" value="${v}" min="${min}" max="${max}" step="${step}" required></label>`).join('')}</div><button id="video-generate" disabled>Video erzeugen</button></form></section>`:''}${!runtime?'<section class="card"><h3>Auftragsstatus</h3><div id="video-job" aria-live="polite"></div><div id="video-result"></div></section>':'<p>Video → Testen bietet einen kurzen Funktionstest; Video → Laufend zeigt den Worker und den aktuellen Auftrag.</p>'}</section>`;
view.innerHTML=html();bind(view.firstElementChild);
} }
function runtime(){const view=document.querySelector('#view');if(view.querySelector('[data-video-view="runtime"]'))return;view.innerHTML=html('runtime');bind(view.firstElementChild);}
function bind(root=document.querySelector('#video-runtime-page')){ function bind(root=document.querySelector('#video-runtime-page')){
if(!root)return; if(!root)return;
const msg=root.querySelector('#video-message'); const msg=root.querySelector('#video-message'),form=root.querySelector('#video-test');let current=null,pending=false,revision=0,pollError=false;
async function action(path,data){try{await api(path,data);msg.textContent='Auftrag angenommen.';}catch(x){msg.textContent=x.message;}} function draw(s){
root.querySelector('#video-install').onclick=()=>action('video-runtime/install',{}); current=s;
root.querySelector('#video-cancel').onclick=()=>action('video-runtime/cancel',{}); const worker=root.querySelector('#video-worker-state');if(worker)worker.innerHTML=workerState(s);
root.querySelector('#video-test button').disabled=true; const ready=s.mode==='video'&&s.state==='ready',running=s.job?.state==='running';
root.querySelector('#video-test').onsubmit=ev=>{ev.preventDefault();const d=Object.fromEntries(new FormData(ev.target));for(const k of ['width','height','frames','fps','seed'])d[k]=Number(d[k]);action('video/generate',d);}; if(form){
async function poll(){if(!root.isConnected)return;try{const s=await api('video');if(!root.isConnected)return;const panel=root.querySelector('#video-test-mode');if(panel){const p=await api('profiles');if(!root.isConnected)return;mode(panel,s,p.profiles);}root.querySelector('#video-status').textContent=s.runtime.installed?'Installiert · '+s.runtime.revision:(s.runtime.job?.phase||'Noch nicht installiert');root.querySelector('#video-install').disabled=s.runtime.installed||s.runtime.job?.state==='running';root.querySelector('#video-cancel').disabled=s.runtime.job?.state!=='running';root.querySelector('#video-test button').disabled=s.mode!=='video'||s.state!=='ready'||s.job?.state==='running';root.querySelector('#video-job').textContent=s.job?`${s.job.state} · ${s.job.phase||''} · ${s.job.id} · ${s.job.error||''}`:'Noch kein Video-Auftrag.';if(s.job?.state==='complete'&&root.querySelector('#video-result').dataset.id!==s.job.id){root.querySelector('#video-result').dataset.id=s.job.id;root.querySelector('#video-result').innerHTML=`<video controls preload="metadata" style="max-width:100%" src="/api/v1/video/content?id=${e(s.job.id)}"></video><a href="/api/v1/video/content?id=${e(s.job.id)}" download="athena-video.mp4">Video herunterladen</a>`;}}catch(x){msg.textContent=x.message;}if(root.isConnected)setTimeout(poll,2000);}poll(); const hint=root.querySelector('#video-mode-hint');hint.innerHTML=ready?'Videomodus bereit. Du kannst einen Test starten.':'Für diesen Test muss der Video-Modus bereit sein. Bitte in der <a class="link" href="#home">Übersicht auf Video umschalten</a>.';
form.querySelector('button').disabled=pending||running||s.state==='switching';
form.querySelector('button').textContent=pending?'Wird gestartet …':running?'Test läuft …':'Video erzeugen';
form.setAttribute('aria-busy',String(pending||running));
} }
return {mode,runtime,html:()=>html(true),bind}; const job=root.querySelector('#video-job');if(job)job.innerHTML=jobState(s.job);
const result=root.querySelector('#video-result');
if(result&&s.job?.state==='complete'&&result.dataset.id!==s.job.id){result.dataset.id=s.job.id;result.innerHTML=`<video controls preload="metadata" class="generated-video" src="/api/v1/video/content?id=${e(s.job.id)}"></video><a class="link" href="/api/v1/video/content?id=${e(s.job.id)}" download="athena-video.mp4">Video herunterladen</a>`;}
else if(result&&s.job?.state!=='complete'){result.replaceChildren();delete result.dataset.id;}
const installation=root.querySelector('#video-install-status');
if(installation){const r=s.runtime;installation.innerHTML=r.job?.state==='running'?busy('Video-Laufzeit wird installiert',r.job.phase):`<p>${e(r.installed?'Installiert':r.job?.phase||'Noch nicht installiert')}</p>`;root.querySelector('#video-install').disabled=pending||r.installed||r.job?.state==='running';root.querySelector('#video-cancel').disabled=pending||r.job?.state!=='running';}
}
async function action(path,data){
if(pending)return;
if(path==='video/generate'&&(!current||current.mode!=='video'||current.state!=='ready')){msg.innerHTML='<p class="video-error">Falscher Modus oder Video noch nicht bereit. Bitte zuerst in der <a class="link" href="#home">Übersicht auf Video umschalten</a>.</p>';return;}
pending=true;revision++;root.querySelectorAll('button').forEach(b=>b.disabled=true);
msg.innerHTML=busy(path==='video/generate'?'Video-Test wird gestartet …':'Anfrage wird verarbeitet …');
try{const value=await api(path,data);if(!root.isConnected)return;
if(path==='video/generate'){current={...current,job:value};msg.textContent='Test gestartet. Der Fortschritt steht unter Auftragsstatus.';root.querySelector('#video-result').replaceChildren();draw(current);}else {current={...current,runtime:value};msg.textContent='Auftrag angenommen.';}
}catch(x){if(root.isConnected)msg.innerHTML=`<p class="video-error" role="alert">${e(x.message)}</p>`;}
finally{pending=false;revision++;if(root.isConnected&&current)draw(current);}
}
root.querySelector('#video-install')?.addEventListener('click',()=>action('video-runtime/install',{}));
root.querySelector('#video-cancel')?.addEventListener('click',()=>action('video-runtime/cancel',{}));
if(form)form.onsubmit=ev=>{ev.preventDefault();const d=Object.fromEntries(new FormData(form));for(const k of ['width','height','frames','fps','seed'])d[k]=Number(d[k]);action('video/generate',d);};
async function poll(){if(!root.isConnected)return;const version=revision;
try{const s=await api('video');if(root.isConnected&&!pending&&version===revision){if(pollError){msg.textContent='Verbindung wiederhergestellt.';pollError=false;}draw(s);}}
catch(x){if(root.isConnected&&!pending&&version===revision){pollError=true;msg.innerHTML=`<p class="video-error">Status momentan nicht erreichbar: ${e(x.message)}. Ein laufender Auftrag kann weiterlaufen.</p>`;current=null;if(form)form.querySelector('button').disabled=true;}}
if(root.isConnected)setTimeout(poll,1500);
}poll();
}
return {mode,runtime,html,bind};
})(); })();
+8 -4
View File
@@ -17,12 +17,12 @@ class Video:
def __init__(self,root,profiles,runtime,scheduler,stop_owned): def __init__(self,root,profiles,runtime,scheduler,stop_owned):
self.root=Path(root);self.profiles=profiles;self.runtime=runtime;self.scheduler=scheduler;self.stop_owned=stop_owned self.root=Path(root);self.profiles=profiles;self.runtime=runtime;self.scheduler=scheduler;self.stop_owned=stop_owned
self.lock=threading.RLock();self.mode='llm';self.state='idle';self.error=None;self.process=None;self.profile=None;self.job=None;self.thread=None;self.gpu=None self.lock=threading.RLock();self.mode='llm';self.state='idle';self.error=None;self.process=None;self.profile=None;self.job=None;self.thread=None;self.gpu=None
self.closing=False;self.selected=None self.switch_phase=None;self.switch_started_at=None;self.target_mode=None;self.closing=False;self.selected=None
if (self.root/'selection.json').exists():self.selected=json.loads((self.root/'selection.json').read_text()).get('profile_id') if (self.root/'selection.json').exists():self.selected=json.loads((self.root/'selection.json').read_text()).get('profile_id')
def status(self): def status(self):
with self.lock: with self.lock:
if self.process and self.process.poll() is not None and self.state=='ready':self.state='failed';self.error='Video-Worker beendet; RAM-OOM oder Laufzeitfehler möglich.' if self.process and self.process.poll() is not None and self.state=='ready':self.state='failed';self.error='Video-Worker beendet; RAM-OOM oder Laufzeitfehler möglich.'
return dict(mode=self.mode,state=self.state,error=self.error,profile_name=self.profile['name'] if self.profile else None,profile_id=self.selected,loaded_profile_id=self.profile['id'] if self.profile else None,gpu=self.gpu,job=dict(self.job) if self.job else None,runtime=self.runtime.status(),memory_policy='Disk-Streaming auf RTX 5080; beide GPUs exklusiv reserviert. Gewichte werden bedarfsgerecht geladen.') return dict(target_mode=self.target_mode,switch_phase=self.switch_phase,switch_started_at=self.switch_started_at,mode=self.mode,state=self.state,error=self.error,profile_name=self.profile['name'] if self.profile else None,profile_id=self.selected,loaded_profile_id=self.profile['id'] if self.profile else None,gpu=self.gpu,job=dict(self.job) if self.job else None,runtime=self.runtime.status(),memory_policy='Disk-Streaming auf RTX 5080; beide GPUs exklusiv reserviert. Gewichte werden bedarfsgerecht geladen.')
def blockers(self,p): def blockers(self,p):
if not video_recipe(p.get('model') or {}):return ['Für diese Videovariante ist kein Worker angebunden.'] if not video_recipe(p.get('model') or {}):return ['Für diese Videovariante ist kein Worker angebunden.']
errors=[] errors=[]
@@ -53,12 +53,13 @@ class Video:
if errors:raise ValueError(' '.join(errors)) if errors:raise ValueError(' '.join(errors))
# Close admission before stopping any process. Queued leases recheck this flag. # Close admission before stopping any process. Queued leases recheck this flag.
with self.scheduler.cv:self.scheduler.gpu_mode='switching';self.scheduler.cv.notify_all() with self.scheduler.cv:self.scheduler.gpu_mode='switching';self.scheduler.cv.notify_all()
self.state='switching';self.error=None self.state='switching';self.error=None;self.target_mode=mode;self.switch_started_at=time.time();self.switch_phase='Laufende Deck-Aufträge beenden'
self.thread=threading.Thread(target=self._switch,args=(mode,profile),daemon=True);self.thread.start() self.thread=threading.Thread(target=self._switch,args=(mode,profile),daemon=True);self.thread.start()
return self.status() return self.status()
def _switch(self,mode,profile): def _switch(self,mode,profile):
try: try:
self.stop_owned();self.stop_process() self.stop_owned();self.stop_process()
with self.lock:self.switch_phase='Auf Freigabe des GPU-Speichers warten'
deadline=time.monotonic()+60 deadline=time.monotonic()+60
with self.scheduler.cv: with self.scheduler.cv:
while self.scheduler.active or self.scheduler.transition: while self.scheduler.active or self.scheduler.transition:
@@ -66,7 +67,9 @@ class Video:
self.scheduler.cv.wait(.2) self.scheduler.cv.wait(.2)
self.scheduler.key=None self.scheduler.key=None
if self.closing:raise ValueError('Deck wird beendet.') if self.closing:raise ValueError('Deck wird beendet.')
if mode=='video':self.load(profile) if mode=='video':
with self.lock:self.switch_phase='GPUs prüfen und Videoprofil vorbereiten'
self.load(profile)
if self.closing:raise ValueError('Deck wird beendet.') if self.closing:raise ValueError('Deck wird beendet.')
with self.lock:self.mode=mode;self.state='ready' if mode=='video' else 'idle' with self.lock:self.mode=mode;self.state='ready' if mode=='video' else 'idle'
with self.scheduler.cv:self.scheduler.gpu_mode=mode;self.scheduler.cv.notify_all() with self.scheduler.cv:self.scheduler.gpu_mode=mode;self.scheduler.cv.notify_all()
@@ -133,6 +136,7 @@ class Video:
p=subprocess.Popen([str(python),str(Path(__file__).with_name('video_worker.py'))],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,bufsize=0,start_new_session=True,env=env) p=subprocess.Popen([str(python),str(Path(__file__).with_name('video_worker.py'))],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,bufsize=0,start_new_session=True,env=env)
with self.lock:self.process=p;self.gpu=gpu['uuid'] with self.lock:self.process=p;self.gpu=gpu['uuid']
if self.closing:self.stop_process();raise ValueError('Deck wird beendet.') if self.closing:self.stop_process();raise ValueError('Deck wird beendet.')
with self.lock:self.switch_phase='Video-Worker laden und Komponenten prüfen'
p.stdin.write((json.dumps(config)+'\n').encode());p.stdin.flush();result=self.read(p,180) p.stdin.write((json.dumps(config)+'\n').encode());p.stdin.flush();result=self.read(p,180)
if result.get('state')!='ready':raise ValueError(result.get('error','Video-Profil nicht bereit.')) if result.get('state')!='ready':raise ValueError(result.get('error','Video-Profil nicht bereit.'))
with self.lock:self.profile=profile with self.lock:self.profile=profile