Add multi-GPU support and HTML test interface
Some checks failed
Build and Push Docker Image / build (push) Failing after 16m23s

- Update SageAttention CUDA arch list to support A100, A10, RTX 4090, L40, H100/H200
- Add interactive HTML test page for RunPod API testing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-01-03 10:25:08 +00:00
parent f69bbc2f45
commit 99fdda5b2b
2 changed files with 615 additions and 2 deletions

View File

@@ -65,9 +65,10 @@ WORKDIR /tmp
ENV EXT_PARALLEL=2
ENV NVCC_APPEND_FLAGS="--threads 2"
ENV MAX_JOBS=4
# Target RunPod GPU architectures: H100/H200(9.0)
# Target RunPod GPU architectures:
# 8.0 = A100, 8.6 = A10/RTX 3090, 8.9 = RTX 4090/L40, 9.0 = H100/H200
# Note: Blackwell (10.0) not yet supported by SageAttention
ENV TORCH_CUDA_ARCH_LIST="9.0"
ENV TORCH_CUDA_ARCH_LIST="8.0;8.6;8.9;9.0"
RUN git clone https://github.com/thu-ml/SageAttention.git && \
cd SageAttention && \
pip install --no-build-isolation . && \

612
test-runpod.html Normal file
View File

@@ -0,0 +1,612 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ComfyUI RunPod Workflow Tester</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 28px;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
font-size: 14px;
}
.content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
padding: 30px;
}
.section {
background: #f8f9fa;
padding: 24px;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.section h2 {
font-size: 18px;
margin-bottom: 20px;
color: #495057;
border-bottom: 2px solid #667eea;
padding-bottom: 10px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #495057;
font-size: 14px;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group textarea,
.form-group select {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group textarea {
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.file-upload {
position: relative;
display: inline-block;
width: 100%;
}
.file-upload input[type="file"] {
display: none;
}
.file-upload-btn {
display: block;
width: 100%;
padding: 12px;
background: white;
border: 2px dashed #ced4da;
border-radius: 6px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
color: #6c757d;
}
.file-upload-btn:hover {
border-color: #667eea;
background: #f8f9ff;
color: #667eea;
}
.file-upload-btn.has-file {
border-color: #28a745;
background: #f0fff4;
color: #28a745;
}
.preview-image {
max-width: 100%;
max-height: 300px;
margin-top: 15px;
border-radius: 6px;
display: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.preview-image.show {
display: block;
}
.btn {
padding: 14px 28px;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
width: 100%;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: #6c757d;
color: white;
margin-top: 10px;
}
.btn-secondary:hover {
background: #5a6268;
}
.status {
margin-top: 20px;
padding: 16px;
border-radius: 6px;
font-size: 14px;
display: none;
}
.status.show {
display: block;
}
.status.info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.status.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.loading {
display: none;
text-align: center;
margin-top: 20px;
}
.loading.show {
display: block;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.output-section {
grid-column: 1 / -1;
}
.output-video {
max-width: 100%;
border-radius: 8px;
margin-top: 15px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.json-output {
background: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: 6px;
margin-top: 15px;
overflow-x: auto;
font-family: 'Courier New', monospace;
font-size: 12px;
max-height: 400px;
overflow-y: auto;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.help-text {
font-size: 12px;
color: #6c757d;
margin-top: 5px;
}
@media (max-width: 768px) {
.content {
grid-template-columns: 1fr;
}
.grid-2 {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎬 ComfyUI RunPod Workflow Tester</h1>
<p>Test your image-to-video generation API with custom parameters</p>
</div>
<div class="content">
<!-- Configuration Section -->
<div class="section">
<h2>⚙️ RunPod Configuration</h2>
<div class="form-group">
<label for="endpointId">Endpoint ID</label>
<input type="text" id="endpointId" placeholder="abc123xyz">
<div class="help-text">Your RunPod serverless endpoint ID (e.g., abc123xyz)</div>
</div>
<div class="form-group">
<label for="apiKey">RunPod API Key</label>
<input type="text" id="apiKey" placeholder="YOUR_RUNPOD_API_KEY">
<div class="help-text">Your RunPod API key for authentication</div>
</div>
<div class="form-group">
<label for="endpointType">Endpoint Type</label>
<select id="endpointType">
<option value="runsync" selected>Synchronous (/runsync)</option>
<option value="run">Asynchronous (/run)</option>
</select>
<div class="help-text">Use synchronous for immediate response, async for long-running tasks</div>
</div>
</div>
<!-- Image Upload Section -->
<div class="section">
<h2>🖼️ Input Image</h2>
<div class="form-group">
<label>Upload Image</label>
<div class="file-upload">
<input type="file" id="imageInput" accept="image/*">
<label for="imageInput" class="file-upload-btn" id="fileUploadBtn">
📁 Click to select image
</label>
</div>
<img id="previewImage" class="preview-image" alt="Preview">
</div>
</div>
<!-- Parameters Section -->
<div class="section">
<h2>✨ Generation Parameters</h2>
<div class="form-group">
<label for="prompt">Positive Prompt</label>
<textarea id="prompt" placeholder="Describe what you want to see in the video...">a beautiful sunset over the ocean, cinematic lighting</textarea>
</div>
<div class="form-group">
<label for="negativePrompt">Negative Prompt (Optional)</label>
<textarea id="negativePrompt" placeholder="What to avoid...">blurry, low quality, distorted</textarea>
</div>
<div class="grid-2">
<div class="form-group">
<label for="resolution">Resolution</label>
<select id="resolution">
<option value="480">480p</option>
<option value="720" selected>720p</option>
<option value="1080">1080p</option>
</select>
</div>
<div class="form-group">
<label for="steps">Steps</label>
<input type="number" id="steps" value="8" min="1" max="50">
</div>
</div>
<div class="grid-2">
<div class="form-group">
<label for="splitStep">Split Step</label>
<input type="number" id="splitStep" value="4" min="1" max="20">
</div>
<div class="form-group">
<label for="timeout">Timeout (seconds)</label>
<input type="number" id="timeout" value="600" min="60" max="600">
</div>
</div>
</div>
<!-- Submit Section -->
<div class="section">
<h2>🚀 Execute</h2>
<button class="btn btn-primary" id="submitBtn" onclick="submitRequest()">
Generate Video
</button>
<button class="btn btn-secondary" onclick="clearResults()">
Clear Results
</button>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>Generating video... This may take a few minutes.</p>
</div>
<div class="status" id="status"></div>
</div>
<!-- Output Section -->
<div class="section output-section">
<h2>📹 Output</h2>
<div id="outputContainer"></div>
<div id="jsonOutput"></div>
</div>
</div>
</div>
<script>
let base64Image = '';
// Image upload handling
document.getElementById('imageInput').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(event) {
const img = document.getElementById('previewImage');
img.src = event.target.result;
img.classList.add('show');
// Store base64 without data URL prefix
base64Image = event.target.result.split(',')[1];
const btn = document.getElementById('fileUploadBtn');
btn.textContent = '✅ ' + file.name;
btn.classList.add('has-file');
};
reader.readAsDataURL(file);
}
});
function showStatus(message, type) {
const status = document.getElementById('status');
status.textContent = message;
status.className = 'status show ' + type;
}
function showLoading(show) {
const loading = document.getElementById('loading');
const btn = document.getElementById('submitBtn');
if (show) {
loading.classList.add('show');
btn.disabled = true;
} else {
loading.classList.remove('show');
btn.disabled = false;
}
}
function clearResults() {
document.getElementById('outputContainer').innerHTML = '';
document.getElementById('jsonOutput').innerHTML = '';
document.getElementById('status').classList.remove('show');
}
async function submitRequest() {
// Validation
const endpointId = document.getElementById('endpointId').value.trim();
const apiKey = document.getElementById('apiKey').value.trim();
const endpointType = document.getElementById('endpointType').value;
if (!endpointId) {
showStatus('❌ Please enter your RunPod endpoint ID', 'error');
return;
}
if (!apiKey) {
showStatus('❌ Please enter your RunPod API key', 'error');
return;
}
// Construct full endpoint URL
const endpoint = `https://api.runpod.ai/v2/${endpointId}/${endpointType}`;
if (!base64Image) {
showStatus('❌ Please upload an image first', 'error');
return;
}
const prompt = document.getElementById('prompt').value.trim();
if (!prompt) {
showStatus('❌ Please enter a prompt', 'error');
return;
}
// Build request payload
const payload = {
input: {
image: base64Image,
prompt: prompt,
negative_prompt: document.getElementById('negativePrompt').value.trim(),
resolution: parseInt(document.getElementById('resolution').value),
steps: parseInt(document.getElementById('steps').value),
split_step: parseInt(document.getElementById('splitStep').value),
timeout: parseInt(document.getElementById('timeout').value)
}
};
// Show loading state
showLoading(true);
showStatus('🚀 Sending request to RunPod...', 'info');
clearResults();
try {
const startTime = Date.now();
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify(payload)
});
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
// Display raw JSON response
document.getElementById('jsonOutput').innerHTML =
'<strong>Raw Response:</strong><pre>' +
JSON.stringify(result, null, 2) +
'</pre>';
// Handle response
if (result.status === 'COMPLETED' && result.output) {
showStatus(`✅ Video generated successfully in ${elapsed}s`, 'success');
const outputContainer = document.getElementById('outputContainer');
if (result.output.video) {
// Base64 video
const videoElem = document.createElement('video');
videoElem.className = 'output-video';
videoElem.controls = true;
videoElem.autoplay = true;
videoElem.loop = true;
videoElem.src = 'data:video/mp4;base64,' + result.output.video;
outputContainer.appendChild(videoElem);
} else if (result.output.image) {
// Base64 image
const imgElem = document.createElement('img');
imgElem.className = 'output-video';
imgElem.src = 'data:image/png;base64,' + result.output.image;
outputContainer.appendChild(imgElem);
} else if (result.output.file_path) {
// File path (large output)
const fileInfo = document.createElement('div');
fileInfo.className = 'status info show';
fileInfo.innerHTML = `
<strong>Large output saved to:</strong><br>
<code>${result.output.file_path}</code><br><br>
<em>File is too large to display (>10MB). Access it on your RunPod volume.</em>
`;
outputContainer.appendChild(fileInfo);
}
} else if (result.status === 'FAILED') {
showStatus('❌ Generation failed: ' + (result.error || 'Unknown error'), 'error');
} else {
showStatus('⚠️ Unexpected response status: ' + result.status, 'error');
}
} catch (error) {
showStatus('❌ Error: ' + error.message, 'error');
console.error('Request failed:', error);
} finally {
showLoading(false);
}
}
// Save configuration to localStorage
document.getElementById('endpointId').addEventListener('change', function(e) {
localStorage.setItem('runpod_endpoint_id', e.target.value);
});
document.getElementById('apiKey').addEventListener('change', function(e) {
localStorage.setItem('runpod_apikey', e.target.value);
});
document.getElementById('endpointType').addEventListener('change', function(e) {
localStorage.setItem('runpod_endpoint_type', e.target.value);
});
// Load saved configuration
window.addEventListener('load', function() {
const savedEndpointId = localStorage.getItem('runpod_endpoint_id');
const savedApiKey = localStorage.getItem('runpod_apikey');
const savedEndpointType = localStorage.getItem('runpod_endpoint_type');
if (savedEndpointId) {
document.getElementById('endpointId').value = savedEndpointId;
}
if (savedApiKey) {
document.getElementById('apiKey').value = savedApiKey;
}
if (savedEndpointType) {
document.getElementById('endpointType').value = savedEndpointType;
}
});
</script>
</body>
</html>