animatorvid / index.html
Opera10's picture
Update index.html
100ff32 verified
Raw
History Blame Contribute Delete
19.5 kB
<!DOCTYPE html>
<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>