/** * useFaceRestorePolling.ts — polling hook for Face Restoration jobs. * * INDEPENDENT of useBgRemovePolling.ts, useUpscalePolling.ts, and * useJobPolling.ts. Structurally mirrors useBgRemovePolling.ts (same * elapsed-timer + recursive-setTimeout pattern) but imports from * facerestoreClient and carries the three extra face-count fields returned * by the /facerestore/result endpoint. * * Why not reuse useBgRemovePolling.ts? * The face restoration result shape differs — it carries faces_detected, * faces_restored, faces_rejected — so parameterizing a shared hook would * push type complexity into it for no benefit. Independent hooks per feature * is the established pattern in this codebase. * * Elapsed timer * ------------- * startPolling() records a start timestamp. A setInterval fires every second * to update elapsedSeconds = floor((now - start) / 1000) for display. * The interval is cleared on completion, failure, or reset. * * Polling * ------- * Recursive-setTimeout: schedules the next poll only after the previous * response arrives. 2 s interval. */ import { useCallback, useEffect, useRef, useState } from "react"; import { getFaceRestoreResult } from "../api/facerestoreClient"; const POLL_INTERVAL_MS = 2000; export type FaceRestorePhase = "idle" | "polling" | "completed" | "failed"; interface FaceRestoreState { phase: FaceRestorePhase; resultImageUrl: string | null; errorMessage: string | null; elapsedSeconds: number; facesDetected: number; facesRestored: number; facesRejected: number; } const INITIAL: FaceRestoreState = { phase: "idle", resultImageUrl: null, errorMessage: null, elapsedSeconds: 0, facesDetected: 0, facesRestored: 0, facesRejected: 0, }; export function useFaceRestorePolling() { const [state, setState] = useState(INITIAL); const pollTimeoutRef = useRef | null>(null); const elapsedInterval = useRef | null>(null); const startTimeRef = useRef(0); const isMountedRef = useRef(true); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; clearTimers(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function clearTimers() { if (pollTimeoutRef.current !== null) { clearTimeout(pollTimeoutRef.current); pollTimeoutRef.current = null; } if (elapsedInterval.current !== null) { clearInterval(elapsedInterval.current); elapsedInterval.current = null; } } const startPolling = useCallback((jobId: string) => { clearTimers(); if (!isMountedRef.current) return; startTimeRef.current = Date.now(); setState({ phase: "polling", resultImageUrl: null, errorMessage: null, elapsedSeconds: 0, facesDetected: 0, facesRestored: 0, facesRejected: 0, }); elapsedInterval.current = setInterval(() => { if (isMountedRef.current) { setState(prev => ({ ...prev, elapsedSeconds: Math.floor((Date.now() - startTimeRef.current) / 1000), })); } }, 1000); const poll = async () => { try { const result = await getFaceRestoreResult(jobId); if (!isMountedRef.current) return; if (result.status === "completed") { clearTimers(); setState({ phase: "completed", resultImageUrl: result.result_image_url, errorMessage: null, elapsedSeconds: Math.floor((Date.now() - startTimeRef.current) / 1000), facesDetected: result.faces_detected, facesRestored: result.faces_restored, facesRejected: result.faces_rejected, }); } else if (result.status === "failed") { clearTimers(); setState(prev => ({ ...prev, phase: "failed", errorMessage: result.error_message ?? "Face restoration failed with no details.", })); } else { // "pending" or "processing" — keep polling pollTimeoutRef.current = setTimeout(poll, POLL_INTERVAL_MS); } } catch (err) { if (!isMountedRef.current) return; clearTimers(); setState(prev => ({ ...prev, phase: "failed", errorMessage: `Network error while polling: ${(err as Error).message}`, })); } }; poll(); }, []); // eslint-disable-line react-hooks/exhaustive-deps const reset = useCallback(() => { clearTimers(); if (isMountedRef.current) setState(INITIAL); }, []); // eslint-disable-line react-hooks/exhaustive-deps return { phase: state.phase, resultImageUrl: state.resultImageUrl, errorMessage: state.errorMessage, elapsedSeconds: state.elapsedSeconds, facesDetected: state.facesDetected, facesRestored: state.facesRestored, facesRejected: state.facesRejected, startPolling, reset, }; }