All checks were successful
Build and Push Docker Image / build (push) Successful in 33m17s
- Disable torch.compile (inductor -> disabled) to reduce cold start time - Fix handler to detect video type from file extension, not output key - Fix HTML to check filename extension for video display 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
694 lines
24 KiB
HTML
694 lines
24 KiB
HTML
<!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 pollForCompletion(endpointId, jobId, apiKey, startTime) {
|
|
const statusUrl = `https://api.runpod.ai/v2/${endpointId}/status/${jobId}`;
|
|
const maxPolls = 120; // 10 minutes with 5s intervals
|
|
let pollCount = 0;
|
|
|
|
while (pollCount < maxPolls) {
|
|
pollCount++;
|
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
|
|
showStatus(`⏳ Generating video... (${elapsed}s elapsed, poll #${pollCount})`, 'info');
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5 seconds
|
|
|
|
try {
|
|
const response = await fetch(statusUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
// Update JSON output
|
|
document.getElementById('jsonOutput').innerHTML =
|
|
'<strong>Raw Response:</strong><pre>' +
|
|
JSON.stringify(result, null, 2) +
|
|
'</pre>';
|
|
|
|
if (result.status === 'COMPLETED' || result.status === 'FAILED') {
|
|
return result;
|
|
}
|
|
|
|
// Still in progress, continue polling
|
|
} catch (error) {
|
|
console.error('Poll error:', error);
|
|
// Continue polling despite errors
|
|
}
|
|
}
|
|
|
|
throw new Error('Timeout: Job did not complete within 10 minutes');
|
|
}
|
|
|
|
async function handleResult(result, startTime, endpointId, apiKey) {
|
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
|
|
// If still in progress, poll for completion
|
|
if (result.status === 'IN_PROGRESS' || result.status === 'IN_QUEUE') {
|
|
showStatus(`⏳ Job ${result.id} is ${result.status}. Polling for completion...`, 'info');
|
|
result = await pollForCompletion(endpointId, result.id, apiKey, startTime);
|
|
|
|
// Update JSON output with final result
|
|
document.getElementById('jsonOutput').innerHTML =
|
|
'<strong>Raw Response:</strong><pre>' +
|
|
JSON.stringify(result, null, 2) +
|
|
'</pre>';
|
|
}
|
|
|
|
const finalElapsed = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
|
|
if (result.status === 'COMPLETED' && result.output) {
|
|
showStatus(`✅ Video generated successfully in ${finalElapsed}s`, 'success');
|
|
|
|
const outputContainer = document.getElementById('outputContainer');
|
|
const output = result.output;
|
|
|
|
// Handler returns: {status, prompt_id, outputs: [{type, filename, data/path, size}]}
|
|
if (output.outputs && output.outputs.length > 0) {
|
|
for (const item of output.outputs) {
|
|
// Determine type from extension (more reliable than type field)
|
|
const filename = item.filename || '';
|
|
const isVideo = /\.(mp4|webm|mov|gif|avi|mkv)$/i.test(filename);
|
|
|
|
if (isVideo && item.data) {
|
|
// 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,' + item.data;
|
|
outputContainer.appendChild(videoElem);
|
|
} else if (!isVideo && item.data) {
|
|
// Base64 image
|
|
const imgElem = document.createElement('img');
|
|
imgElem.className = 'output-video';
|
|
imgElem.src = 'data:image/png;base64,' + item.data;
|
|
outputContainer.appendChild(imgElem);
|
|
} else if (item.path) {
|
|
// File path (large output saved to volume)
|
|
const fileInfo = document.createElement('div');
|
|
fileInfo.className = 'status info show';
|
|
fileInfo.innerHTML = `
|
|
<strong>Large output saved to:</strong><br>
|
|
<code>${item.path}</code><br>
|
|
<em>File: ${item.filename} (${(item.size / 1024 / 1024).toFixed(2)} MB)</em><br><br>
|
|
<em>File is too large to display (>10MB). Access it on your RunPod volume.</em>
|
|
`;
|
|
outputContainer.appendChild(fileInfo);
|
|
}
|
|
}
|
|
} else if (output.error) {
|
|
showStatus('❌ Handler error: ' + output.error, 'error');
|
|
} else {
|
|
showStatus('⚠️ No outputs in response', 'error');
|
|
}
|
|
} else if (result.status === 'FAILED') {
|
|
showStatus('❌ Generation failed: ' + (result.error || 'Unknown error'), 'error');
|
|
} else {
|
|
showStatus('⚠️ Unexpected response status: ' + result.status, 'error');
|
|
}
|
|
}
|
|
|
|
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
|
|
await handleResult(result, startTime, endpointId, apiKey);
|
|
|
|
} 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>
|