Replace Deck video generation with selected external service lifecycle

This commit is contained in:
Mikei386
2026-09-29 14:24:52 +02:00
parent 71e8345606
commit badc7701fa
23 changed files with 219 additions and 636 deletions
+9 -68
View File
@@ -1,74 +1,15 @@
window.VideoUI=(()=>{
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;}
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?' · Vorbereitet: '+e(s.profile_name):''}</p>${s.profile_name&&s.devices?`<p>Videopipeline: ${e(s.devices.video||'unbekannt')} · Textencoder: ${e(s.devices.text_encoder||'unbekannt')}</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){
async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;}
function mode(root,s){
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>${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;
root.querySelectorAll('[data-mode]').forEach(b=>b.onclick=async()=>{
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);
});
root.innerHTML=`<section class="card"><h2>GPU-Modus</h2><button data-mode="llm" ${s.mode==='llm'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${!s.service?.id||s.mode==='video'||s.state==='switching'?'disabled':''}>Video</button><h3>${e(s.service?.name||'Kein Videodienst eingerichtet')}</h3>${s.state==='switching'?`<div class="video-activity" role="status"><strong>${e(s.switch_phase)}</strong><progress></progress></div>`:`<p>${s.mode==='llm'?'LLM-Betrieb · Videodienst gestoppt':s.service?.ready?'Video-API bereit':s.service?.running?'Videodienst läuft · API noch nicht bereit':'Dienststatus nicht prüfbar · GPU-Aufträge gesperrt'}</p>`}${s.service?.api_url?`<p>Original-API: <code>${e(s.service.api_url)}</code></p><p>${e(s.service.message)}</p>`:''}${s.error?`<p class="video-error" role="alert">${e(s.error)}</p>`:''}<p>Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.</p><a href="#services">Aktiven Videodienst unter Weitere Dienste wählen</a><div class="video-message" role="status"></div></section>`;
root.querySelectorAll('[data-mode]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';root.querySelectorAll('button').forEach(x=>x.disabled=true);root.querySelector('.video-message').textContent='Moduswechsel wird angefordert …';try{const next=await api('video/mode',{mode:b.dataset.mode});root.dataset.busy='0';mode(root,next);}catch(x){root.querySelector('.video-message').textContent=x.message;root.dataset.busy='0';}});
}
function html(view='test'){
const test=view==='test',runtime=view==='runtime';
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; Gerätezuordnung im Videoprofil.</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>`;
function services(root){
if(!root||root.dataset.bound)return;root.dataset.bound='1';
async function poll(){if(!root.isConnected)return;try{const s=await api('video');if(root.dataset.busy!=='1'){root.innerHTML=`<section class="card"><h2>Aktiver Videodienst</h2><p>Die Übersicht startet den hier gewählten Dienst. Jeder Dienst behält seine eigene API; keine Übersetzung von Generierungsparametern.</p>${s.services.map(x=>`<label><input type="radio" name="video-service" value="${e(x.id)}" ${x.id===s.selected?'checked':''} ${s.mode!=='llm'?'disabled':''}> ${e(x.name)} · ${e(x.api_url)}</label>`).join('')||'<p>Noch kein Videodienst durch den Administrator zugeordnet.</p>'}<p>Zum Wechseln zuerst in der Übersicht auf LLM schalten.</p><p class="video-message" role="status"></p></section>`;root.querySelectorAll('input').forEach(b=>b.onchange=async()=>{root.dataset.busy='1';try{await api('video/service',{id:b.value});root.querySelector('.video-message').textContent='Videodienst aktiviert. Start über die Übersicht.';}catch(x){root.querySelector('.video-message').textContent=x.message;}finally{root.dataset.busy='0';}});}}catch(x){root.textContent=x.message;}if(root.isConnected)setTimeout(poll,5000);}poll();
}
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')){
if(!root)return;
const msg=root.querySelector('#video-message'),form=root.querySelector('#video-test');let current=null,pending=false,revision=0,pollError=false;
function draw(s){
current=s;
const worker=root.querySelector('#video-worker-state');if(worker)worker.innerHTML=workerState(s);
const ready=s.mode==='video'&&s.state==='ready',running=s.job?.state==='running';
if(form){
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));
}
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};
function runtime(){const view=document.querySelector('#view');if(view.querySelector('#video-service-page'))return;view.innerHTML='<section id="video-service-page"><h1>Videodienst</h1><p>Deck verwaltet keine Videoprofile. Der aktive Dienst wird in der Übersicht gestartet und über seine Original-API bedient.</p><a href="#home">Zur Übersicht</a><div id="video-services"></div></section>';services(view.querySelector('#video-services'));}
return {mode,services,runtime};
})();