Spaces:
Running
Running
| <html lang="fa" dir="rtl"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | |
| <title>Wan2.2-Animate API Client</title> | |
| <style> | |
| * { box-sizing: border-box; } | |
| body { | |
| font-family: sans-serif; | |
| max-width: 720px; | |
| margin: 0 auto; | |
| padding: 16px; | |
| background:#111; | |
| color:#eee; | |
| } | |
| h1 { font-size: 19px; line-height: 1.4; } | |
| label { display:block; margin-top: 18px; font-weight: bold; font-size: 14px; } | |
| input, select { | |
| margin-top: 6px; | |
| padding: 12px; | |
| width: 100%; | |
| font-size: 16px; /* از zoom خودکار سافاری روی iOS جلوگیری میکند */ | |
| background:#222; color:#eee; border:1px solid #444; border-radius:8px; | |
| } | |
| input[type="file"] { padding: 10px 8px; } | |
| button { | |
| margin-top: 20px; | |
| padding: 14px 20px; | |
| font-size: 16px; | |
| border:none; | |
| border-radius: 10px; | |
| background: linear-gradient(45deg,#ff416c,#ff9b00); | |
| color:#fff; | |
| cursor:pointer; | |
| width: 100%; | |
| } | |
| button:disabled { opacity: .5; cursor:not-allowed; } | |
| #log { | |
| margin-top: 20px; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| background:#000; | |
| padding:12px; | |
| border-radius:8px; | |
| font-size:12.5px; | |
| max-height:300px; | |
| overflow:auto; | |
| } | |
| video { margin-top: 16px; max-width: 100%; border-radius: 8px; display:block; } | |
| #statusBar { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| gap: 10px; | |
| margin-top: 18px; | |
| padding: 12px 14px; | |
| border-radius: 10px; | |
| background: #1a1a1a; | |
| border: 1px solid #333; | |
| font-size: 14px; | |
| } | |
| #statusDot { | |
| width: 12px; height: 12px; border-radius: 50%; | |
| background: #555; | |
| flex-shrink: 0; | |
| } | |
| #statusDot.working { background: #ffb300; animation: pulse 1s infinite ease-in-out; } | |
| #statusDot.done { background: #4caf50; animation: none; } | |
| #statusDot.error { background: #f44336; animation: none; } | |
| #statusDot.partial { background: #ff9800; animation: none; } | |
| @keyframes pulse { 0%,100%{ opacity:1; } 50%{ opacity:.35; } } | |
| #statusText { flex: 1 1 200px; min-width: 0; word-break: break-word; } | |
| #statusTimer { font-variant-numeric: tabular-nums; color:#aaa; font-size: 13px; } | |
| #resumeBox { | |
| margin-top: 16px; | |
| padding: 14px; | |
| border: 1px solid #ff9800; | |
| border-radius: 10px; | |
| background: #241a08; | |
| display: none; | |
| } | |
| #resumeBox h3 { margin: 0 0 8px 0; font-size: 15px; color: #ffb74d; } | |
| #resumeBox p { margin: 4px 0; font-size: 13px; color: #ddd; line-height: 1.9; } | |
| .resumeActions { display: flex; gap: 10px; margin-top: 10px; flex-wrap: wrap; } | |
| .resumeActions a, .resumeActions button { | |
| padding: 10px 14px; border-radius: 8px; font-size: 13px; text-decoration: none; | |
| border: 1px solid #555; background:#2a2a2a; color:#eee; cursor:pointer; | |
| flex: 1 1 auto; text-align: center; margin-top: 0; width: auto; | |
| } | |
| .resumeActions .primary { background: #ff9800; color:#000; border:none; font-weight:bold; } | |
| @media (max-width: 480px) { | |
| body { padding: 12px; } | |
| h1 { font-size: 17px; } | |
| .resumeActions { flex-direction: column; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>کلاینت API عمومی Wan2.2-Animate (Gradio)</h1> | |
| <label>توکن Hugging Face (اختیاری، برای Space های private یا محدودیت کمتر)</label> | |
| <input id="hfToken" type="password" placeholder="hf_xxxxxxxxxxxx"> | |
| <label>ویدیوی ورودی</label> | |
| <input id="videoFile" type="file" accept="video/*"> | |
| <label>تصویر مرجع (Ref Image)</label> | |
| <input id="imageFile" type="file" accept="image/*"> | |
| <label>حالت</label> | |
| <select id="mode"> | |
| <option value="Character Swap">Character Swap</option> | |
| <option value="Pose Retarget">Pose Retarget</option> | |
| </select> | |
| <label>کیفیت</label> | |
| <select id="res"> | |
| <option value="Medium Res">Medium Res</option> | |
| <option value="High Res">High Res</option> | |
| </select> | |
| <label>فایل وضعیت (.pt) قبلی — فقط اگر میخواهی یک رندر نیمهکاره را ادامه بدهی</label> | |
| <input id="stateFileInput" type="file" accept=".pt"> | |
| <button id="runBtn">شروع پردازش (Wan Animate)</button> | |
| <div id="statusBar"> | |
| <div id="statusDot"></div> | |
| <div id="statusText">آماده شروع</div> | |
| <div id="statusTimer"></div> | |
| </div> | |
| <div id="resumeBox"> | |
| <h3>⚠️ رندر کامل نشد — فایل وضعیت (resume) ساخته شد</h3> | |
| <p id="resumeMsg"></p> | |
| <p>چون سهمیه GPU هاگینگفیس تمام شده، باید فردا (یا بعد از تمدید سهمیه/تغییر IP) دوباره برگردی. فایل زیر را دانلود کن و دفعه بعد در کادر «فایل وضعیت (.pt) قبلی» بالا آپلودش کن و دوباره دکمه شروع را بزن تا از همانجا ادامه پیدا کند.</p> | |
| <div class="resumeActions"> | |
| <a id="resumeDownload" class="primary" download="wan_state.pt">⬇️ دانلود فایل وضعیت (.pt)</a> | |
| <button id="resumeContinueNow">🔄 ادامه فوری بدون دانلود (در همین جلسه)</button> | |
| </div> | |
| </div> | |
| <div id="log"></div> | |
| <video id="result" controls style="display:none;"></video> | |
| <script> | |
| const BASE = "https://opera8-wan2-2-animate-zerogpu.hf.space"; | |
| const logEl = document.getElementById('log'); | |
| const resultEl = document.getElementById('result'); | |
| const runBtn = document.getElementById('runBtn'); | |
| const statusDot = document.getElementById('statusDot'); | |
| const statusText = document.getElementById('statusText'); | |
| const statusTimer = document.getElementById('statusTimer'); | |
| const resumeBox = document.getElementById('resumeBox'); | |
| const resumeMsg = document.getElementById('resumeMsg'); | |
| const resumeDownload = document.getElementById('resumeDownload'); | |
| const resumeContinueNow = document.getElementById('resumeContinueNow'); | |
| const stateFileInput = document.getElementById('stateFileInput'); | |
| // وقتی سرور یک فایل وضعیت (.pt) برمیگرداند، همینجا نگه میداریم تا بدون دانلود/آپلود | |
| // بتوان همان لحظه دوباره فرستادش (مثل دکمه «انتقال» در خود اسپیس اصلی) | |
| let pendingResumeFileData = null; | |
| function log(msg) { | |
| logEl.textContent += msg + "\n"; | |
| logEl.scrollTop = logEl.scrollHeight; | |
| } | |
| // --- تایمر و نوار وضعیت --- | |
| let timerStart = null; | |
| let timerInterval = null; | |
| function fmtElapsed(ms) { | |
| const s = Math.floor(ms / 1000); | |
| const mm = String(Math.floor(s / 60)).padStart(2, "0"); | |
| const ss = String(s % 60).padStart(2, "0"); | |
| return `${mm}:${ss}`; | |
| } | |
| function startTimer() { | |
| timerStart = Date.now(); | |
| clearInterval(timerInterval); | |
| timerInterval = setInterval(() => { | |
| statusTimer.textContent = fmtElapsed(Date.now() - timerStart); | |
| }, 500); | |
| } | |
| function stopTimer() { | |
| clearInterval(timerInterval); | |
| } | |
| // state: 'idle' | 'working' | 'done' | 'error' | |
| function setStatus(state, text) { | |
| statusDot.className = state === 'idle' ? '' : state; | |
| statusText.textContent = text; | |
| if (state === 'working' && !timerInterval) startTimer(); | |
| if (state === 'done' || state === 'error' || state === 'partial') stopTimer(); | |
| } | |
| // تلاش برای استخراج پیام فارسی/انسانی از یک رویداد generating | |
| function extractFriendlyText(dataLine) { | |
| try { | |
| const arr = JSON.parse(dataLine); | |
| if (Array.isArray(arr)) { | |
| for (const item of arr) { | |
| if (item && typeof item === 'object' && typeof item.value === 'string') { | |
| return item.value.replace(/\*\*/g, "").replace(/[⏳🔄✅⚠️]/g, "").trim(); | |
| } | |
| } | |
| } | |
| } catch (e) { /* داده JSON نبود، مشکلی نیست */ } | |
| return null; | |
| } | |
| function authHeaders() { | |
| const token = document.getElementById('hfToken').value.trim(); | |
| return token ? { "Authorization": "Bearer " + token } : {}; | |
| } | |
| // آپلود یک فایل به Gradio و برگرداندن مسیر سرور | |
| async function uploadFile(file) { | |
| const form = new FormData(); | |
| form.append("files", file, file.name); | |
| const res = await fetch(`${BASE}/gradio_api/upload`, { | |
| method: "POST", | |
| headers: authHeaders(), | |
| body: form | |
| }); | |
| if (!res.ok) throw new Error("Upload failed: " + res.status); | |
| const paths = await res.json(); // آرایهای از مسیرها | |
| return { | |
| path: paths[0], | |
| meta: { "_type": "gradio.FileData" }, | |
| orig_name: file.name | |
| }; | |
| } | |
| // پیدا کردن نام دقیق endpoint از روی /gradio_api/info (در صورت نیاز) | |
| async function getEndpointName() { | |
| const res = await fetch(`${BASE}/gradio_api/info`, { headers: authHeaders() }); | |
| const info = await res.json(); | |
| // معمولا نام تابع پایتون همان نام endpoint است، اینجا "animate_scene" را امتحان میکنیم | |
| // اگر پیدا نشد، اولین named_endpoint موجود را برمیگردانیم | |
| const named = info.named_endpoints || {}; | |
| if (named["/animate_scene"]) return "animate_scene"; | |
| const keys = Object.keys(named); | |
| log("توجه: endpoint دقیق پیدا نشد، اولین مورد موجود انتخاب شد: " + keys[0]); | |
| return keys[0]?.replace(/^\//, ""); | |
| } | |
| // فراخوانی endpoint و دریافت event_id | |
| async function callEndpoint(endpoint, data) { | |
| const res = await fetch(`${BASE}/gradio_api/call/${endpoint}`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json", ...authHeaders() }, | |
| body: JSON.stringify({ data }) | |
| }); | |
| if (!res.ok) throw new Error("Call failed: " + res.status + " " + await res.text()); | |
| const j = await res.json(); | |
| return j.event_id; | |
| } | |
| // یک بار تلاش برای خواندن استریم SSE. اگر استریم بدون 'complete' قطع شود، null برمیگرداند (یعنی باید دوباره وصل شد). | |
| // اگر خود سرور خطای واقعی برگرداند (event: error)، یک خطای fatal پرتاب میشود که دیگر نباید retry شود. | |
| async function attemptStream(url) { | |
| const res = await fetch(url, { headers: authHeaders() }); | |
| if (!res.ok) throw new Error("HTTP " + res.status); | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ""; | |
| while (true) { | |
| let chunk; | |
| try { | |
| chunk = await reader.read(); | |
| } catch (e) { | |
| // قطعی شبکه حین خواندن - قابل تلاش مجدد | |
| throw new Error("network-read-failed"); | |
| } | |
| const { value, done } = chunk; | |
| if (done) return null; // اتصال بدون رسیدن به complete بسته شد -> باید reconnect شود | |
| buffer += decoder.decode(value, { stream: true }); | |
| const parts = buffer.split("\n\n"); | |
| buffer = parts.pop(); | |
| for (const part of parts) { | |
| const lines = part.split("\n"); | |
| let eventType = "message"; | |
| let dataLine = ""; | |
| for (const line of lines) { | |
| if (line.startsWith("event:")) eventType = line.slice(6).trim(); | |
| if (line.startsWith("data:")) dataLine += line.slice(5).trim(); | |
| } | |
| if (!dataLine) continue; | |
| log(`[${eventType}] ${dataLine.slice(0, 300)}`); | |
| if (eventType === "generating" || eventType === "message") { | |
| const friendly = extractFriendlyText(dataLine); | |
| if (friendly) setStatus('working', friendly); | |
| } | |
| if (eventType === "complete") { | |
| return JSON.parse(dataLine); | |
| } | |
| if (eventType === "error") { | |
| const err = new Error("پاسخ خطا از سرور: " + dataLine.slice(0, 300)); | |
| err.fatal = true; | |
| throw err; | |
| } | |
| } | |
| } | |
| } | |
| // خواندن نتیجه با قابلیت اتصال مجدد خودکار در صورت قطعی شبکه/پراکسی | |
| async function pollResult(endpoint, eventId) { | |
| const url = `${BASE}/gradio_api/call/${endpoint}/${eventId}`; | |
| const maxRetries = 6; | |
| for (let attempt = 1; attempt <= maxRetries; attempt++) { | |
| try { | |
| const result = await attemptStream(url); | |
| if (result) return result; // به 'complete' رسیدیم | |
| // استریم بدون خطای صریح ولی بدون complete بسته شد -> تلاش مجدد | |
| setStatus('working', `اتصال قطع شد، تلاش مجدد برای اتصال (${attempt}/${maxRetries})...`); | |
| } catch (err) { | |
| if (err.fatal) { | |
| setStatus('error', err.message); | |
| throw err; | |
| } | |
| setStatus('working', `خطای شبکه (${err.message})، تلاش مجدد ${attempt}/${maxRetries}...`); | |
| } | |
| await new Promise(r => setTimeout(r, 1500 * attempt)); | |
| } | |
| const finalErr = new Error("اتصال بعد از چند بار تلاش برقرار نشد. لطفا اتصال اینترنت را بررسی کن و دوباره امتحان کن."); | |
| setStatus('error', finalErr.message); | |
| throw finalErr; | |
| } | |
| // بهجای تکیه روی اندیس ثابت، بین همهی آیتمهای خروجی دنبال فایلی با پسوند مشخص میگردیم. | |
| // اینطوری اگر ترتیب یا تعداد خروجیها کمی فرق کند، باز هم درست کار میکند. | |
| function findFileByExt(dataArr, exts) { | |
| if (!Array.isArray(dataArr)) return null; | |
| for (const item of dataArr) { | |
| if (item && typeof item === 'object' && typeof item.url === 'string') { | |
| const lower = item.url.toLowerCase().split('?')[0]; | |
| if (exts.some(e => lower.endsWith(e))) return item; | |
| } | |
| } | |
| return null; | |
| } | |
| // آخرین متن Markdown/وضعیتی که در خروجی پیدا شود (برای نمایش پیام دقیق سرور) | |
| function findStatusMessage(dataArr) { | |
| if (!Array.isArray(dataArr)) return null; | |
| for (const item of dataArr) { | |
| if (item && typeof item === 'object' && typeof item.value === 'string') { | |
| return item.value.replace(/\*\*/g, "").replace(/[⏳🔄✅⚠️]/g, "").trim(); | |
| } | |
| } | |
| return null; | |
| } | |
| runBtn.addEventListener('click', async () => { | |
| const videoInput = document.getElementById('videoFile').files[0]; | |
| const imageInput = document.getElementById('imageFile').files[0]; | |
| const uploadedStateFile = stateFileInput.files[0]; | |
| const hasResumeSource = !!uploadedStateFile || !!pendingResumeFileData; | |
| // اگر فایل وضعیت (resume) داریم، دیگر لازم نیست حتما ویدیو/تصویر تازه انتخاب شود | |
| if (!hasResumeSource && (!videoInput || !imageInput)) { | |
| alert("لطفا هم ویدیو و هم تصویر مرجع را انتخاب کنید (یا یک فایل وضعیت .pt برای ادامه رندر بده)."); | |
| return; | |
| } | |
| runBtn.disabled = true; | |
| resultEl.style.display = "none"; | |
| resumeBox.style.display = "none"; | |
| logEl.textContent = ""; | |
| statusTimer.textContent = ""; | |
| setStatus('working', 'در حال آمادهسازی...'); | |
| try { | |
| let videoFileData = null; | |
| let imageFileData = null; | |
| if (videoInput) { | |
| setStatus('working', 'در حال آپلود ویدیو...'); | |
| log("در حال آپلود ویدیو..."); | |
| videoFileData = await uploadFile(videoInput); | |
| } | |
| if (imageInput) { | |
| setStatus('working', 'در حال آپلود تصویر مرجع...'); | |
| log("در حال آپلود تصویر مرجع..."); | |
| imageFileData = await uploadFile(imageInput); | |
| } | |
| // تعیین فایل resume: اولویت با فایلی که کاربر همین الان آپلود کرده، بعد فایلی که از رندر قبلی در حافظه مانده | |
| let trackingIdIn = null; | |
| if (uploadedStateFile) { | |
| setStatus('working', 'در حال آپلود فایل وضعیت (.pt)...'); | |
| log("در حال آپلود فایل وضعیت resume..."); | |
| trackingIdIn = await uploadFile(uploadedStateFile); | |
| } else if (pendingResumeFileData) { | |
| log("استفاده از فایل وضعیت رندر قبلی (بدون آپلود مجدد)..."); | |
| trackingIdIn = pendingResumeFileData; | |
| } | |
| setStatus('working', 'در حال یافتن endpoint...'); | |
| log("در حال یافتن endpoint..."); | |
| const endpoint = await getEndpointName(); | |
| log("Endpoint: " + endpoint); | |
| // ترتیب پارامترها باید دقیقا مطابق ورودیهای تابع در سمت سرور باشد: | |
| // [input_video, edited_frame, replace_character_string, resolution_choice, prompt_state, tracking_id_in] | |
| const data = [ | |
| videoFileData, | |
| imageFileData, | |
| document.getElementById('mode').value, | |
| document.getElementById('res').value, | |
| { "selections": {}, "active_frame": 0, "cur_label_val": 1.0 }, // prompt_state پیشفرض | |
| trackingIdIn | |
| ]; | |
| setStatus('working', 'در حال فراخوانی مدل (ممکن است چند دقیقه طول بکشد)...'); | |
| log("در حال فراخوانی مدل (ممکن است طول بکشد)..."); | |
| const eventId = await callEndpoint(endpoint, data); | |
| log("Event ID: " + eventId); | |
| const result = await pollResult(endpoint, eventId); | |
| log("نتیجه نهایی دریافت شد."); | |
| log("داده کامل خروجی: " + JSON.stringify(result)); | |
| // توجه: خود payload رویداد complete مستقیما همان آرایهی خروجیهاست (نه چیزی مثل {data:[...]}) | |
| const outVideo = findFileByExt(result, ['.mp4', '.mov', '.webm', '.mkv']); | |
| const outStateFile = findFileByExt(result, ['.pt']); | |
| const friendlyFinal = findStatusMessage(result); | |
| if (outVideo && outVideo.url) { | |
| // رندر کامل شده - ویدیوی نهایی آماده است | |
| resultEl.src = outVideo.url; | |
| resultEl.style.display = "block"; | |
| pendingResumeFileData = null; | |
| setStatus('done', `تمام شد ✅ (زمان کل: ${fmtElapsed(Date.now() - timerStart)})`); | |
| } else if (outStateFile && outStateFile.url) { | |
| // رندر کامل نشد - فایل وضعیت (resume) برگشته، دقیقا مثل رفتار خود اسپیس اصلی | |
| pendingResumeFileData = outStateFile; | |
| resumeMsg.textContent = friendlyFinal || "بخشی از رندر انجام شد. برای ادامه باید دوباره فراخوانی شود."; | |
| resumeDownload.href = outStateFile.url; | |
| resumeBox.style.display = "block"; | |
| setStatus('partial', 'رندر ناقص - فایل وضعیت آماده است (پایین صفحه را ببین)'); | |
| log("فایل وضعیت (state) دریافت شد: " + outStateFile.url); | |
| } else { | |
| setStatus('error', 'پاسخ نهایی نه ویدیوی کامل داشت و نه فایل وضعیت. کل پاسخ را در لاگ بررسی کن.'); | |
| log("نه ویدیوی خروجی و نه فایل وضعیت در پاسخ پیدا شد."); | |
| } | |
| } catch (err) { | |
| setStatus('error', 'خطا: ' + err.message); | |
| log("خطا: " + err.message); | |
| console.error(err); | |
| } finally { | |
| runBtn.disabled = false; | |
| } | |
| }); | |
| // دکمه «ادامه فوری بدون دانلود»: همان درخواست را دوباره میزند، این بار با فایل وضعیت موجود در حافظه | |
| resumeContinueNow.addEventListener('click', () => { | |
| if (!pendingResumeFileData) { | |
| alert("فایل وضعیتی در حافظه نیست."); | |
| return; | |
| } | |
| stateFileInput.value = ""; // مطمئن شو از نسخهی حافظه استفاده میشود نه یک آپلود دستی | |
| runBtn.click(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |