feat(lipsync): add cancel button, poll status display, and abort support

- Surfaces live API status (queued/processing) + poll count during generation
- Cancel button aborts both the fetch and the polling loop instantly
- Threads AbortController.signal through pollForResult/submitAndPoll/processLipSync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gal Inbar 2026-04-29 14:52:35 +03:00
commit b673287a2d
2 changed files with 57 additions and 9 deletions

View file

@ -371,7 +371,10 @@ export default function LipSyncStudio({
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.
@ -640,12 +643,20 @@ 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;
@ -687,11 +698,19 @@ export default function LipSyncStudio({
} 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");
@ -970,15 +989,32 @@ export default function LipSyncStudio({
)}
</div>
{/* Generation progress bar */}
{/* Generation progress bar + status */}
{isGenerating && (
<div className="px-1 pb-1">
<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>
)}

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 (params.prompt) 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) {