This commit is contained in:
Gal Inbar 2026-05-05 17:01:55 +02:00 committed by GitHub
commit 1502ec3c59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 139 additions and 16 deletions

View file

@ -14,7 +14,7 @@ export const metadata = {
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className={inter.variable}>{children}</body>
<body className={inter.variable} suppressHydrationWarning>{children}</body>
</html>
);
}

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "open-generative-ai",
"version": "1.0.8",
"version": "1.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-generative-ai",
"version": "1.0.8",
"version": "1.0.9",
"workspaces": [
"packages/studio",
"packages/Vibe-Workflow/packages/workflow-builder",

View file

@ -217,7 +217,14 @@ function Dropdown({ isOpen, items, selectedId, onSelect, onClose, anchorRef }) {
: "text-white font-medium"
}`}
>
<div>{item.name}</div>
<div className="flex items-center gap-1.5">
<span>{item.name}</span>
{item.maxDuration && (
<span className="px-1.5 py-0.5 rounded bg-white/5 border border-white/10 text-[10px] font-bold text-white/40 leading-none">
Max {item.maxDuration}s
</span>
)}
</div>
{item.description && (
<div className="text-xs text-muted mt-0.5">
{item.description.slice(0, 60)}...
@ -363,6 +370,11 @@ export default function LipSyncStudio({
const [fullscreenUrl, setFullscreenUrl] = useState(null);
const [view, setView] = useState("input"); // 'input' | 'result'
const [activeResultUrl, setActiveResultUrl] = useState(null);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [pollStatus, setPollStatus] = useState(null); // e.g. "queued", "processing"
const [pollAttempt, setPollAttempt] = useState(0);
const genTimerRef = useRef(null);
const abortControllerRef = useRef(null);
// History
// If historyItems prop is provided, use it; otherwise use internal state.
@ -630,11 +642,21 @@ export default function LipSyncStudio({
setIsGenerating(true);
setGenerateError(null);
setElapsedSeconds(0);
setPollStatus(null);
setPollAttempt(0);
genTimerRef.current = setInterval(() => setElapsedSeconds((s) => s + 1), 1000);
abortControllerRef.current = new AbortController();
try {
const lipsyncParams = {
model: selectedModelId,
audio_url: audioUrl,
signal: abortControllerRef.current.signal,
onPollStatus: ({ attempt, status }) => {
setPollStatus(status);
setPollAttempt(attempt);
},
};
if (inputMode === "image") lipsyncParams.image_url = imageUrl;
else lipsyncParams.video_url = videoUrl;
@ -674,10 +696,21 @@ export default function LipSyncStudio({
setGenerateError(e.message?.slice(0, 80) ?? "Unknown error");
setTimeout(() => setGenerateError(null), 4000);
} finally {
clearInterval(genTimerRef.current);
genTimerRef.current = null;
abortControllerRef.current = null;
setIsGenerating(false);
setElapsedSeconds(0);
setPollStatus(null);
setPollAttempt(0);
}
};
// Cancel generation
const handleCancel = () => {
abortControllerRef.current?.abort();
};
// Reset to input view
const handleNew = () => {
setView("input");
@ -722,6 +755,12 @@ export default function LipSyncStudio({
name: r,
}));
// Generation progress helpers
const formatElapsed = (s) =>
s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
// Logarithmic fill: fast start, slows toward 95%, never hits 100% until done
const genProgress = Math.min(95, (1 - Math.exp(-elapsedSeconds / 150)) * 100);
// Render
return (
<div className="w-full h-full flex flex-col items-center justify-center bg-app-bg relative overflow-hidden">
@ -950,6 +989,35 @@ export default function LipSyncStudio({
)}
</div>
{/* Generation progress bar + status */}
{isGenerating && (
<div className="px-1 pb-1 flex flex-col gap-1.5">
<div className="w-full h-1 bg-white/[0.05] rounded-full overflow-hidden">
<div
className="h-full bg-[#d9ff00] rounded-full transition-all duration-1000 ease-out"
style={{ width: `${genProgress}%` }}
/>
</div>
<div className="flex items-center justify-between px-0.5">
<span className="text-[10px] text-white/30 font-mono">
{pollStatus ? (
<span className={pollStatus === 'queued' ? 'text-yellow-500/60' : pollStatus === 'processing' ? 'text-[#d9ff00]/60' : 'text-white/30'}>
{pollStatus}
</span>
) : '● connecting...'}
<span className="text-white/20 ml-1">· poll {pollAttempt}</span>
</span>
<button
type="button"
onClick={handleCancel}
className="text-[10px] text-white/30 hover:text-red-400 transition-colors font-medium"
>
Cancel
</button>
</div>
</div>
)}
{/* Bottom controls row */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-2 border-t border-white/[0.03] relative">
<div className="flex items-center gap-2 px-1">
@ -996,6 +1064,16 @@ export default function LipSyncStudio({
/>
</div>
{/* Duration limit pill */}
{selectedModel?.maxDuration && (
<span className="flex items-center gap-1 px-2 py-1 rounded-md bg-white/[0.03] border border-white/[0.03] text-[10px] font-bold text-white/30 whitespace-nowrap">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
Max {selectedModel.maxDuration}s
</span>
)}
{/* Resolution selector */}
{showResolution && (
<div className="relative">
@ -1035,10 +1113,8 @@ export default function LipSyncStudio({
>
{isGenerating ? (
<>
<span className="animate-spin inline-block text-black">
</span>{" "}
Generating...
<span className="animate-spin inline-block text-black"></span>
<span>Generating... {formatElapsed(elapsedSeconds)}</span>
</>
) : generateError ? (
`Error: ${generateError}`

View file

@ -8257,6 +8257,7 @@ export const lipsyncModels = [
"family": "infinitetalk",
"category": "image",
"hasPrompt": true,
"maxDuration": 60,
"description": "Animate a portrait image into a talking video driven by audio.",
"inputs": {
"resolution": {
@ -8275,6 +8276,7 @@ export const lipsyncModels = [
"family": "wan",
"category": "image",
"hasPrompt": true,
"maxDuration": 60,
"description": "Generate a talking portrait video from an image and audio using Wan 2.2.",
"inputs": {
"resolution": {
@ -8294,6 +8296,7 @@ export const lipsyncModels = [
"category": "image",
"hasPrompt": true,
"hasSeed": true,
"maxDuration": 20,
"description": "High-quality lipsync from portrait image and audio using LTX 2.3.",
"inputs": {
"resolution": {
@ -8312,6 +8315,7 @@ export const lipsyncModels = [
"family": "ltx",
"category": "image",
"hasPrompt": true,
"maxDuration": 20,
"description": "Lipsync from portrait image and audio using LTX 2 19B model.",
"inputs": {
"resolution": {
@ -8331,6 +8335,7 @@ export const lipsyncModels = [
"family": "lipsync",
"category": "video",
"hasPrompt": false,
"maxDuration": 60,
"description": "Generate realistic lipsync animations from audio using Sync's advanced algorithms."
},
{
@ -8340,6 +8345,7 @@ export const lipsyncModels = [
"family": "lipsync",
"category": "video",
"hasPrompt": false,
"maxDuration": 30,
"description": "Video-to-video lipsync using LatentSync for high-quality audio-driven lip animations."
},
{
@ -8349,6 +8355,7 @@ export const lipsyncModels = [
"family": "lipsync",
"category": "video",
"hasPrompt": false,
"maxDuration": 60,
"description": "Realistic lipsync video optimized for speed, quality, and consistency by Creatify."
},
{
@ -8358,6 +8365,7 @@ export const lipsyncModels = [
"family": "lipsync",
"category": "video",
"hasPrompt": false,
"maxDuration": 120,
"description": "Generate realistic lipsync from any audio using VEED's latest model."
},
{
@ -8367,6 +8375,7 @@ export const lipsyncModels = [
"family": "infinitetalk",
"category": "video",
"hasPrompt": true,
"maxDuration": 60,
"description": "Apply audio-driven lipsync to an existing video using Infinite Talk.",
"inputs": {
"resolution": {

View file

@ -3,13 +3,19 @@ import { getModelById, getVideoModelById, getI2IModelById, getI2VModelById, getV
const BASE_URL = 'https://api.muapi.ai';
const PROXY_WF_BASE = '/api/workflow';
async function pollForResult(requestId, key, maxAttempts = 900, interval = 2000) {
async function pollForResult(requestId, key, maxAttempts = 900, interval = 2000, { signal, onPollStatus } = {}) {
const pollUrl = `${BASE_URL}/api/v1/predictions/${requestId}/result`;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
await new Promise(resolve => setTimeout(resolve, interval));
if (signal?.aborted) throw new Error('Generation cancelled.');
await new Promise((resolve, reject) => {
const t = setTimeout(resolve, interval);
signal?.addEventListener('abort', () => { clearTimeout(t); reject(new Error('Generation cancelled.')); }, { once: true });
});
if (signal?.aborted) throw new Error('Generation cancelled.');
try {
const response = await fetch(pollUrl, {
headers: { 'Content-Type': 'application/json', 'x-api-key': key }
headers: { 'Content-Type': 'application/json', 'x-api-key': key },
signal,
});
if (!response.ok) {
const errText = await response.text();
@ -18,21 +24,24 @@ async function pollForResult(requestId, key, maxAttempts = 900, interval = 2000)
}
const data = await response.json();
const status = data.status?.toLowerCase();
if (onPollStatus) onPollStatus({ attempt, maxAttempts, status, requestId });
if (status === 'completed' || status === 'succeeded' || status === 'success') return data;
if (status === 'failed' || status === 'error') throw new Error(`Generation failed: ${data.error || 'Unknown error'}`);
} catch (error) {
if (error.message === 'Generation cancelled.' || error.name === 'AbortError') throw new Error('Generation cancelled.');
if (attempt === maxAttempts) throw error;
}
}
throw new Error('Generation timed out after polling.');
}
async function submitAndPoll(endpoint, payload, key, onRequestId, maxAttempts = 60) {
async function submitAndPoll(endpoint, payload, key, onRequestId, maxAttempts = 60, { signal, onPollStatus } = {}) {
const url = `${BASE_URL}/api/v1/${endpoint}`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': key },
body: JSON.stringify(payload)
body: JSON.stringify(payload),
signal,
});
if (!response.ok) {
const errText = await response.text();
@ -42,7 +51,7 @@ async function submitAndPoll(endpoint, payload, key, onRequestId, maxAttempts =
const requestId = submitData.request_id || submitData.id;
if (!requestId) return submitData;
if (onRequestId) onRequestId(requestId);
const result = await pollForResult(requestId, key, maxAttempts);
const result = await pollForResult(requestId, key, maxAttempts, 2000, { signal, onPollStatus });
const outputUrl = result.outputs?.[0] || result.url || result.output?.url;
return { ...result, url: outputUrl };
}
@ -137,7 +146,10 @@ export async function processLipSync(apiKey, params) {
if (modelInfo?.hasPrompt) payload.prompt = params.prompt || '';
if (params.resolution) payload.resolution = params.resolution;
if (params.seed !== undefined && params.seed !== -1) payload.seed = params.seed;
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900);
return submitAndPoll(endpoint, payload, apiKey, params.onRequestId, 900, {
signal: params.signal,
onPollStatus: params.onPollStatus,
});
}
export function uploadFile(apiKey, file, onProgress) {

View file

@ -303,8 +303,16 @@ export function LipSyncStudio() {
generateBtn.className = 'ml-auto px-6 py-2.5 bg-primary text-black font-black text-sm rounded-2xl hover:scale-105 active:scale-95 transition-all shadow-glow disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100';
generateBtn.textContent = 'Generate ✨';
// Duration limit pill — updates when model changes
const durationPill = document.createElement('span');
durationPill.id = 'ls-duration-pill';
durationPill.className = 'px-2.5 py-1 rounded-lg bg-white/5 border border-white/10 text-xs font-bold text-muted flex items-center gap-1';
const currentModelInitial = getCurrentModel();
durationPill.innerHTML = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="text-muted"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><span id="ls-duration-pill-text">Max: ${currentModelInitial?.maxDuration ? currentModelInitial.maxDuration + 's' : '—'}</span>`;
bottomRow.appendChild(modelBtn);
bottomRow.appendChild(resolutionBtn);
bottomRow.appendChild(durationPill);
bottomRow.appendChild(generateBtn);
bar.appendChild(bottomRow);
@ -333,7 +341,10 @@ export function LipSyncStudio() {
const item = document.createElement('button');
item.type = 'button';
item.className = `w-full text-left px-4 py-2.5 rounded-xl text-sm transition-all hover:bg-white/10 ${m.id === selectedModel ? 'text-primary font-bold bg-primary/5' : 'text-white font-medium'}`;
item.innerHTML = `<div>${m.name}</div><div class="text-xs text-muted mt-0.5">${m.description?.slice(0, 60)}...</div>`;
const durationTag = m.maxDuration
? `<span class="ml-1.5 px-1.5 py-0.5 rounded-md bg-white/5 border border-white/10 text-[10px] font-bold text-muted">Max ${m.maxDuration}s</span>`
: '';
item.innerHTML = `<div class="flex items-center gap-1">${m.name}${durationTag}</div><div class="text-xs text-muted mt-0.5">${m.description?.slice(0, 60)}...</div>`;
item.onclick = () => {
selectedModel = m.id;
document.getElementById('ls-model-btn-label').textContent = m.name;
@ -346,6 +357,8 @@ export function LipSyncStudio() {
resolutionBtn.classList.add('hidden');
}
textarea.style.display = m.hasPrompt ? '' : 'none';
const pillText = document.getElementById('ls-duration-pill-text');
if (pillText) pillText.textContent = `Max: ${m.maxDuration ? m.maxDuration + 's' : '—'}`;
closeDropdown();
};
dropdown.appendChild(item);
@ -435,6 +448,10 @@ export function LipSyncStudio() {
// Show/hide prompt
textarea.style.display = models[0].hasPrompt ? '' : 'none';
// Update duration pill
const pillText = document.getElementById('ls-duration-pill-text');
if (pillText) pillText.textContent = `Max: ${models[0].maxDuration ? models[0].maxDuration + 's' : '—'}`;
};
imageModeBtn.onclick = () => {

View file

@ -8273,6 +8273,7 @@ export const lipsyncModels = [
"family": "infinitetalk",
"category": "image",
"hasPrompt": true,
"maxDuration": 60,
"description": "Animate a portrait image into a talking video driven by audio.",
"inputs": {
"resolution": {
@ -8291,6 +8292,7 @@ export const lipsyncModels = [
"family": "wan",
"category": "image",
"hasPrompt": true,
"maxDuration": 60,
"description": "Generate a talking portrait video from an image and audio using Wan 2.2.",
"inputs": {
"resolution": {
@ -8310,6 +8312,7 @@ export const lipsyncModels = [
"category": "image",
"hasPrompt": true,
"hasSeed": true,
"maxDuration": 20,
"description": "High-quality lipsync from portrait image and audio using LTX 2.3.",
"inputs": {
"resolution": {
@ -8328,6 +8331,7 @@ export const lipsyncModels = [
"family": "ltx",
"category": "image",
"hasPrompt": true,
"maxDuration": 20,
"description": "Lipsync from portrait image and audio using LTX 2 19B model.",
"inputs": {
"resolution": {
@ -8347,6 +8351,7 @@ export const lipsyncModels = [
"family": "lipsync",
"category": "video",
"hasPrompt": false,
"maxDuration": 60,
"description": "Generate realistic lipsync animations from audio using Sync's advanced algorithms."
},
{
@ -8356,6 +8361,7 @@ export const lipsyncModels = [
"family": "lipsync",
"category": "video",
"hasPrompt": false,
"maxDuration": 30,
"description": "Video-to-video lipsync using LatentSync for high-quality audio-driven lip animations."
},
{
@ -8365,6 +8371,7 @@ export const lipsyncModels = [
"family": "lipsync",
"category": "video",
"hasPrompt": false,
"maxDuration": 60,
"description": "Realistic lipsync video optimized for speed, quality, and consistency by Creatify."
},
{
@ -8374,6 +8381,7 @@ export const lipsyncModels = [
"family": "lipsync",
"category": "video",
"hasPrompt": false,
"maxDuration": 120,
"description": "Generate realistic lipsync from any audio using VEED's latest model."
},
{
@ -8383,6 +8391,7 @@ export const lipsyncModels = [
"family": "infinitetalk",
"category": "video",
"hasPrompt": true,
"maxDuration": 60,
"description": "Apply audio-driven lipsync to an existing video using Infinite Talk.",
"inputs": {
"resolution": {