src/models/SoulX-LiveAct/templates/index.html
23,550 bytes · 754 lines · capsule://quake0day/[email protected]
raw on github
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>LiveAct</title>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<style>
:root {
--primary: #6366f1;
--accent: #06b6d4;
--bg: #f4f7f6;
--panel: #ffffff;
--text-main: #2d3436;
--text-sub: #636e72;
--border: #e2e8f0;
}
* {
box-sizing: border-box;
}
body {
background: var(--bg);
color: var(--text-main);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
margin: 0;
width: 100vw;
height: 100vh;
display: flex;
overflow: hidden;
}
.config-side {
flex: 0 0 580px;
display: flex;
flex-direction: column;
height: 100vh;
overflow-y: auto;
padding: 32px 24px;
background: var(--panel);
border-right: 1px solid var(--border);
}
.view-side {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 32px;
min-width: 0;
}
h2 {
font-size: 24px;
margin-bottom: 25px;
margin-top: 0;
background: linear-gradient(135deg, var(--primary), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 800;
}
label {
font-size: 12px;
color: var(--text-sub);
margin-bottom: 8px;
display: block;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.single-prompt-box {
margin-bottom: 20px;
}
.main-prompt {
width: 100%;
background: #fff;
border: 1px solid var(--border);
color: var(--text-main);
padding: 12px 14px;
border-radius: 12px;
font-size: 14px;
outline: none;
transition: all 0.2s ease;
}
.main-prompt:hover {
border-color: var(--primary);
background: #fff;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.08);
}
.main-prompt:focus {
border-color: var(--primary);
background: #fff;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.08);
}
.prompt-item {
background: #f8fafc;
padding: 12px 15px;
border-radius: 12px;
margin-bottom: 12px;
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
transition: all 0.2s ease;
}
.prompt-item:hover {
border-color: var(--primary);
background: #fff;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.08);
}
.prompt-item input[type="number"] {
width: 55px;
text-align: center;
background: #fff;
border: 1px solid var(--border);
color: var(--text-main);
padding: 8px;
border-radius: 8px;
outline: none;
}
.p-content {
flex: 1;
min-width: 0;
background: #fff;
border: 1px solid var(--border);
color: var(--text-main);
padding: 8px 14px;
border-radius: 8px;
font-size: 14px;
outline: none;
}
.p-content:focus,
.prompt-item input:focus {
border-color: var(--primary);
}
.time-label {
font-size: 13px;
color: var(--text-sub);
white-space: nowrap;
}
.remove-btn {
color: #ff7675;
cursor: pointer;
font-size: 18px;
font-weight: bold;
padding: 0 5px;
transition: 0.2s;
user-select: none;
}
.remove-btn:hover {
color: #d63031;
transform: scale(1.1);
}
.add-btn {
background: #fff;
color: var(--text-sub);
border: 2px dashed var(--border);
padding: 12px;
border-radius: 12px;
cursor: pointer;
margin-bottom: 25px;
transition: 0.2s;
font-size: 14px;
font-weight: 500;
}
.add-btn:hover {
border-color: var(--primary);
color: var(--primary);
background: #f5f3ff;
}
.fps-box {
margin-bottom: 25px;
}
.upload-area {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 25px;
}
.file-card {
aspect-ratio: 1 / 1;
width: 100%;
border: 2px dashed var(--border);
border-radius: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
overflow: hidden;
background: #fbfbfb;
transition: 0.2s;
color: var(--text-sub);
font-size: 13px;
padding: 10px;
}
.file-card:hover {
border-color: var(--primary);
background: #f5f3ff;
color: var(--primary);
}
.file-card img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: #fff;
padding: 8px;
}
.file-card audio {
width: 90%;
z-index: 2;
}
.btn-start {
background: var(--primary);
color: white;
border: none;
padding: 16px;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
font-size: 16px;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.25);
transition: 0.3s;
}
.btn-start:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4);
}
.btn-start:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.video-frame {
width: min(72vh, 72vw, 760px);
aspect-ratio: 1 / 1;
background: #fff;
border: 1px solid var(--border);
border-radius: 20px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 20px 40px rgba(0,0,0,0.10);
}
video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
display: block;
}
.video-info {
margin-top: 15px;
font-size: 12px;
color: #555;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
.video-resolution {
color: var(--text-sub);
}
#status-bar {
margin-top: 15px;
font-size: 13px;
color: var(--primary);
font-family: 'JetBrains Mono', monospace;
text-align: center;
font-weight: 500;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.config-side::-webkit-scrollbar {
width: 6px;
}
.config-side::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 10px;
}
.file-card audio:not([style*="display:none"]) ~ span {
margin-top: 8px;
font-size: 11px;
text-align: center;
z-index: 2;
}
.resolution-box {
margin-bottom: 25px;
}
.resolution-value {
width: 100%;
background: #fff;
border: 1px solid var(--border);
color: var(--text-main);
padding: 12px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.4;
}
.audio-option-box {
margin-bottom: 25px;
}
.audio-option-row {
display: flex;
align-items: center;
gap: 10px;
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
font-size: 14px;
color: var(--text-main);
}
.audio-option-row input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.task-status-card {
width: min(72vh, 72vw, 760px);
margin-top: 16px;
padding: 14px 16px;
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
font-size: 14px;
line-height: 1.8;
color: var(--text-main);
box-shadow: 0 8px 24px rgba(0,0,0,0.06);
}
.task-status-card strong {
color: var(--text-sub);
font-weight: 600;
}
.task-message {
margin-top: 8px;
color: var(--text-sub);
word-break: break-word;
}
.audio-option-box {
margin-bottom: 25px;
}
.audio-option-row {
display: flex;
align-items: center;
gap: 10px;
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
font-size: 14px;
color: var(--text-main);
}
.audio-option-row input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="config-side">
<h2>LiveAct</h2>
<label>Prompt <span style="color:#ef4444">*</span></label>
<div class="single-prompt-box">
<input type="text" id="main-prompt" class="main-prompt" placeholder="Enter main prompt..." required>
</div>
<label>Edit Prompt (Optional)</label>
<div id="prompt-list"></div>
<button class="add-btn" onclick="addPrompt()">+ Add Time Segment</button>
<div class="fps-box">
<label>FPS: <span id="fps_val" style="color:var(--primary)">20</span></label>
<input
type="range"
id="fps"
min="1"
max="60"
value="20"
style="width:100%;"
oninput="document.getElementById('fps_val').innerText=this.value"
>
</div>
<div class="resolution-box">
<label>Resolution</label>
<div class="resolution-value">{{ stream_resolution }}</div>
</div>
<div class="audio-option-box">
<label>Stream Audio</label>
<div class="audio-option-row">
<input type="checkbox" id="stream_with_audio" checked>
<span>播放时带音频</span>
</div>
</div>
<label>Base Assets</label>
<div class="upload-area">
<div class="file-card" onclick="document.getElementById('img_i').click()">
<img id="img_p" style="display:none">
<span id="img_t">Upload Image</span>
<input type="file" id="img_i" accept="image/*" hidden>
</div>
<div class="file-card" id="audio_card" onclick="document.getElementById('audio_i').click()">
<audio id="audio_p" controls style="display:none;" onclick="event.stopPropagation()"></audio>
<span id="audio_t">Upload Audio</span>
<input type="file" id="audio_i" accept="audio/*" hidden>
</div>
</div>
<button class="btn-start" id="startBtn" onclick="submitTask()">Generate & Stream</button>
<div id="status-bar">Ready</div>
</div>
<div class="view-side">
<div class="video-frame">
<video id="video" controls></video>
</div>
<div class="video-info">
<span>HLS REAL-TIME VIDEO FEED</span>
<span class="video-resolution">Resolution: {{ stream_resolution }}</span>
</div>
<div class="task-status-card">
<div><strong>Task State:</strong> <span id="task-state">Idle</span></div>
<div><strong>Chunk Progress:</strong> <span id="chunk-progress">0 / 0</span></div>
<div><strong>Stream Ready:</strong> <span id="stream-ready">No</span></div>
<div><strong>Done:</strong> <span id="task-done">No</span></div>
<div class="task-message" id="task-message">等待任务启动</div>
</div>
</div>
<script>
const video = document.getElementById('video');
const btn = document.getElementById('startBtn');
const status = document.getElementById('status-bar');
let statusPollTimer = null;
function stopStatusPolling() {
if (statusPollTimer) {
clearInterval(statusPollTimer);
statusPollTimer = null;}
}
function renderTaskStatus(data) {
document.getElementById('task-state').innerText = data.stage || data.status || 'unknown';
document.getElementById('chunk-progress').innerText =
`${data.generated_chunks ?? 0} / ${data.total_chunks ?? '?'}`;
document.getElementById('stream-ready').innerText = data.stream_ready ? 'Yes' : 'No';
document.getElementById('task-done').innerText = data.is_done ? 'Yes' : 'No';
document.getElementById('task-message').innerText = data.message || '';
if (data.status === 'failed') {
status.innerText = data.message || '生成失败';
status.style.color = "#ff5252";
btn.disabled = false;
} else if (data.status === 'finished') {
status.innerText = "生成完成";
status.style.color = "var(--primary)";
btn.disabled = false;
} else if (data.status === 'running') {
status.innerText = data.message || "任务执行中...";
status.style.color = "var(--accent)";
} else if (data.status === 'queued') {
status.innerText = data.message || "排队中...";
status.style.color = "var(--accent)";
}
}
function startStatusPolling(taskId) {
stopStatusPolling();
const tick = async () => {
try {
const res = await fetch(`/task_status/${taskId}`, { cache: 'no-cache' });
if (!res.ok) return;
const data = await res.json();
renderTaskStatus(data);
if (data.status === 'finished' || data.status === 'failed' || data.is_done) {
stopStatusPolling();
}
} catch (e) {
console.warn("状态轮询失败:", e);
}
};
tick();
statusPollTimer = setInterval(tick, 1000);
}
function addPrompt() {
const container = document.getElementById('prompt-list');
const div = document.createElement('div');
div.className = 'prompt-item';
const items = document.querySelectorAll('.prompt-item');
let lastTo = 0;
if (items.length > 0) {
lastTo = items[items.length - 1].querySelector('.p-to').value;
}
div.innerHTML = `
<input type="number" class="p-from" value="${lastTo}" placeholder="0">
<span class="time-label">to</span>
<input type="number" class="p-to" value="${parseInt(lastTo) + 2}" placeholder="2">
<span class="time-label">segment :</span>
<input type="text" class="p-content" placeholder="输入当前时段提示词...">
<span class="remove-btn" onclick="this.parentElement.remove()">×</span>
`;
container.appendChild(div);
}
function getPromptData() {
const items = document.querySelectorAll('.prompt-item');
let data = [];
items.forEach(item => {
const start = parseInt(item.querySelector('.p-from').value) || 0;
const end = parseInt(item.querySelector('.p-to').value) || 0;
const text = item.querySelector('.p-content').value;
if (text.trim() !== "") {
data.push([start, end, text]);
}
});
return data;
}
document.getElementById('img_i').onchange = function(e) {
const file = e.target.files[0];
if (file) {
const url = URL.createObjectURL(file);
const imgPreview = document.getElementById('img_p');
const imgText = document.getElementById('img_t');
imgPreview.src = url;
imgPreview.style.display = 'block';
imgText.style.display = 'none';
}
};
document.getElementById('audio_i').onchange = function(e) {
const file = e.target.files[0];
if (file) {
const url = URL.createObjectURL(file);
const audioPreview = document.getElementById('audio_p');
const audioText = document.getElementById('audio_t');
audioPreview.src = url;
audioPreview.style.display = 'block';
audioText.style.fontSize = '10px';
audioText.style.marginTop = '6px';
audioText.innerText = "Selected: " + (
file.name.length > 15 ? file.name.substring(0, 12) + "..." : file.name
);
audioPreview.load();
}
};
async function submitTask() {
const imgFile = document.getElementById('img_i').files[0];
const audioFile = document.getElementById('audio_i').files[0];
const prompts = getPromptData();
const mainPrompt = document.getElementById('main-prompt').value;
const streamWithAudio = document.getElementById('stream_with_audio').checked;
if (!imgFile || !audioFile) {
alert("请完整上传图片和音频素材!");
return;
}
// if (!mainPrompt) {
// alert("请填写主提示词!");
// return;
// }
const taskId = "task_" + Date.now();
const formData = new FormData();
formData.append('task_id', taskId);
formData.append('main_prompt', mainPrompt);
formData.append('prompt_json', JSON.stringify(prompts));
formData.append('fps', document.getElementById('fps').value);
formData.append('img_file', imgFile);
formData.append('audio_file', audioFile);
formData.append('stream_with_audio', streamWithAudio ? 'true' : 'false');
btn.disabled = true;
status.innerText = "正在上传素材并启动 GPU 引擎...";
status.style.color = "var(--accent)";
try {
const response = await fetch('/start_stream', {
method: 'POST',
body: formData
});
if (response.ok) {
const streamUrl = `/stream/${taskId}/live.m3u8`;
status.innerText = "素材上传成功,等待首个视频切片...";
startStatusPolling(taskId);
pollStreamReady(streamUrl, streamWithAudio);
} else {
let errMsg = "服务器启动失败";
try {
const errData = await response.json();
errMsg = errData.message || errMsg;
} catch (_) {}
throw new Error(errMsg);
}
} catch (err) {
status.innerText = "启动失败: " + err.message;
status.style.color = "#ff5252";
btn.disabled = false;
}
}
async function pollStreamReady(url, streamWithAudio) {
const maxAttempts = 300;
for (let i = 0; i < maxAttempts; i++) {
try {
const check = await fetch(url, { method: 'HEAD', cache: 'no-cache' });
if (check.ok) {
status.innerText = "检测到切片,播放中...";
initHlsPlayer(url, streamWithAudio);
return;
}
} catch (e) {}
await new Promise(r => setTimeout(r, 2000));
}
status.innerText = "超时:GPU 未能在规定时间内生成切片";
status.style.color = "#ff5252";
btn.disabled = false;
}
function initHlsPlayer(url, streamWithAudio) {
if (window._currentHls) {
window._currentHls.destroy();
window._currentHls = null;
}
video.muted = !streamWithAudio;
video.volume = 1.0;
if (Hls.isSupported()) {
const hls = new Hls({
manifestLoadingMaxRetry: 10,
manifestLoadingRetryDelay: 1000,
});
window._currentHls = hls;
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => {
status.innerText = "自动播放被拦截,请手动点击播放";
});
btn.disabled = false;
status.style.color = "var(--primary)";
});
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.error("HLS 关键错误:", data.type, data);
status.innerText = "播放出现错误,请重新生成";
status.style.color = "#ff5252";
btn.disabled = false;
hls.destroy();
window._currentHls = null;
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
video.addEventListener('loadedmetadata', () => {
video.play().catch(() => {
status.innerText = "自动播放被拦截,请手动点击播放";
});
btn.disabled = false;
status.style.color = "var(--primary)";
}, { once: true });
} else {
status.innerText = "当前浏览器不支持 HLS 播放";
status.style.color = "#ff5252";
btn.disabled = false;
}
}
</script>
</body>
</html>