| <!DOCTYPE html> |
| <html lang="fa" dir="rtl"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>کلاینت API اسپیس LTX-2.3</title> |
| <style> |
| :root{ |
| --bg:#0f1115; --panel:#171a21; --border:#2a2f3a; --text:#eef1f6; |
| --muted:#9aa3b2; --accent:#ffc107; --accent2:#ffd54f; --danger:#ef4444; --ok:#22c55e; |
| } |
| *{box-sizing:border-box} |
| body{ |
| margin:0; padding:24px; min-height:100vh; |
| background:var(--bg); color:var(--text); |
| font-family:'Vazirmatn','Tahoma',sans-serif; |
| display:flex; justify-content:center; |
| } |
| .wrap{width:100%; max-width:640px} |
| h1{font-size:1.3rem; margin:0 0 4px} |
| .sub{color:var(--muted); font-size:.85rem; margin-bottom:20px} |
| .card{ |
| background:var(--panel); border:1px solid var(--border); border-radius:16px; |
| padding:20px; margin-bottom:16px; |
| } |
| label{display:block; font-size:.85rem; color:var(--muted); margin-bottom:6px} |
| input[type=text], input[type=url], input[type=number], textarea{ |
| width:100%; background:#0d0f14; color:var(--text); border:1px solid var(--border); |
| border-radius:10px; padding:10px 12px; font-family:inherit; font-size:.95rem; |
| } |
| textarea{resize:vertical; min-height:80px} |
| .row{display:flex; gap:12px; margin-bottom:14px} |
| .row > div{flex:1} |
| .field{margin-bottom:14px} |
| .toggle-row{display:flex; align-items:center; justify-content:space-between; margin-bottom:14px} |
| input[type=file]{ |
| width:100%; background:#0d0f14; border:1px dashed var(--border); border-radius:10px; padding:10px; color:var(--muted); |
| } |
| button{ |
| cursor:pointer; border:none; border-radius:12px; font-weight:800; font-size:1rem; padding:14px; |
| width:100%; |
| } |
| #genBtn{ |
| background:linear-gradient(95deg,var(--accent2),var(--accent) 100%); color:#111827; |
| box-shadow:0 8px 20px -6px rgba(255,193,7,.4); |
| } |
| #genBtn:disabled{opacity:.5; cursor:not-allowed} |
| #status{font-size:.85rem; color:var(--muted); margin-top:10px; min-height:20px; white-space:pre-wrap} |
| #status.err{color:var(--danger)} |
| #status.ok{color:var(--ok)} |
| video{width:100%; border-radius:12px; margin-top:14px; display:none; background:#000} |
| .hint{font-size:.75rem; color:var(--muted); margin-top:4px} |
| .barwrap{height:8px; background:#0d0f14; border-radius:6px; overflow:hidden; margin-top:10px; display:none} |
| .bar{height:100%; width:0%; background:var(--accent); transition:width .3s} |
| </style> |
| </head> |
| <body> |
| <div class="wrap"> |
| <h1>کلاینت مستقیم API اسپیس LTX-2.3</h1> |
| <div class="sub">اتصال مستقیم به Gradio API اسپیس (بدون رابط گرافیکی اصلی)</div> |
|
|
| <div class="card"> |
| <div class="field"> |
| <label>آدرس اسپیس (Base URL)</label> |
| <input type="url" id="spaceUrl" value="https://opera8-ltx-2-3.hf.space"> |
| </div> |
| <div class="field"> |
| <label>توکن Hugging Face (اختیاری — فقط برای اسپیسهای خصوصی/گیتدار)</label> |
| <input type="text" id="hfToken" placeholder="hf_xxxxxxxxxxxxxxxx"> |
| </div> |
| </div> |
|
|
| <div class="card"> |
| <div class="field"> |
| <label>پرامپت (توضیحات متنی)</label> |
| <textarea id="prompt" placeholder="مثلاً: یک فضانورد از یک تخم شکننده روی سطح ماه بیرون میآید..."></textarea> |
| </div> |
|
|
| <div class="field"> |
| <label>تصویر ورودی (اختیاری)</label> |
| <input type="file" id="imageFile" accept="image/*"> |
| </div> |
|
|
| <div class="row"> |
| <div> |
| <label>مدت زمان (ثانیه)</label> |
| <input type="number" id="duration" value="4" min="1" max="15" step="0.5"> |
| </div> |
| <div> |
| <label>Seed</label> |
| <input type="number" id="seed" value="42"> |
| </div> |
| </div> |
|
|
| <div class="toggle-row"> |
| <label style="margin:0">تصادفیسازی Seed</label> |
| <input type="checkbox" id="randomizeSeed" checked> |
| </div> |
| <div class="toggle-row"> |
| <label style="margin:0">بهبود خودکار پرامپت</label> |
| <input type="checkbox" id="enhancePrompt" checked> |
| </div> |
| <div class="toggle-row"> |
| <label style="margin:0">کیفیت بالا (High-Res)</label> |
| <input type="checkbox" id="highRes" checked> |
| </div> |
|
|
| <div class="row"> |
| <div> |
| <label>عرض (Width)</label> |
| <input type="number" id="width" value="1536"> |
| </div> |
| <div> |
| <label>ارتفاع (Height)</label> |
| <input type="number" id="height" value="1024"> |
| </div> |
| </div> |
| <div class="hint" id="aspectHint">اگر تصویری آپلود کنید، عرض/ارتفاع بهصورت خودکار بر اساس نسبت ابعاد همان تصویر تنظیم میشود.</div> |
| </div> |
|
|
| <div class="card"> |
| <div class="field"> |
| <label>fn_index دستی ساخت ویدیو (اختیاری — فقط اگر تشخیص خودکار مبهم بود)</label> |
| <input type="number" id="fnIndexOverride" placeholder="مثلاً 3"> |
| </div> |
| <div class="field"> |
| <label>fn_index دستی بهروزرسانی وضوح/مدت (اختیاری)</label> |
| <input type="number" id="fnIndexToggleOverride" placeholder="مثلاً 1"> |
| </div> |
| </div> |
|
|
| <button id="genBtn" onclick="generate()">🎬 تولید ویدیو</button> |
| <div class="barwrap" id="barwrap"><div class="bar" id="bar"></div></div> |
| <div id="status"></div> |
| <video id="outVideo" controls></video> |
| </div> |
|
|
|
|
| <script> |
| |
| const RESOLUTIONS = { |
| high: { "16:9": [1536, 1024], "9:16": [1024, 1536], "1:1": [1024, 1024] }, |
| low: { "16:9": [768, 512], "9:16": [512, 768], "1:1": [768, 768] } |
| }; |
| |
| function sessionHash() { |
| return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); |
| } |
| |
| function setStatus(msg, cls) { |
| const el = document.getElementById('status'); |
| el.textContent = msg; |
| el.className = cls || ''; |
| } |
| |
| function authHeaders() { |
| const token = document.getElementById('hfToken').value.trim(); |
| return token ? { "Authorization": "Bearer " + token } : {}; |
| } |
| |
| |
| |
| async function safeText(res) { |
| try { return await res.text(); } catch (e) { return "(بدون بدنه پاسخ)"; } |
| } |
| |
| |
| |
| function unwrapValue(v) { |
| if (v && typeof v === 'object' && '__type__' in v && 'value' in v) return v.value; |
| return v; |
| } |
| |
| |
| function detectAspectRatio(w, h) { |
| const ratio = w / h; |
| const candidates = { "16:9": 16 / 9, "9:16": 9 / 16, "1:1": 1.0 }; |
| let best = "16:9", bestDiff = Infinity; |
| for (const key in candidates) { |
| const diff = Math.abs(ratio - candidates[key]); |
| if (diff < bestDiff) { best = key; bestDiff = diff; } |
| } |
| return best; |
| } |
| |
| |
| function getImageDimensions(file) { |
| return new Promise((resolve, reject) => { |
| const img = new Image(); |
| const url = URL.createObjectURL(file); |
| img.onload = () => { |
| resolve({ w: img.naturalWidth, h: img.naturalHeight }); |
| URL.revokeObjectURL(url); |
| }; |
| img.onerror = () => { |
| URL.revokeObjectURL(url); |
| reject(new Error("فایل انتخابشده یک تصویر معتبر نیست.")); |
| }; |
| img.src = url; |
| }); |
| } |
| |
| |
| |
| |
| async function syncResolutionToImage() { |
| const fileInput = document.getElementById('imageFile'); |
| const highRes = document.getElementById('highRes').checked; |
| const tier = highRes ? "high" : "low"; |
| const hint = document.getElementById('aspectHint'); |
| const durationInput = document.getElementById('duration'); |
| |
| let aspect = "16:9"; |
| if (fileInput.files.length > 0) { |
| try { |
| const { w, h } = await getImageDimensions(fileInput.files[0]); |
| aspect = detectAspectRatio(w, h); |
| hint.textContent = `تصویر شما ${w}×${h} است → نزدیکترین نسبت تشخیص دادهشده: ${aspect}`; |
| } catch (err) { |
| hint.textContent = err.message; |
| return; |
| } |
| } else { |
| hint.textContent = "اگر تصویری آپلود کنید، عرض/ارتفاع بهصورت خودکار بر اساس نسبت ابعاد همان تصویر تنظیم میشود."; |
| } |
| |
| const [w, h] = RESOLUTIONS[tier][aspect]; |
| document.getElementById('width').value = w; |
| document.getElementById('height').value = h; |
| |
| const maxDur = highRes ? 15 : 35; |
| durationInput.max = maxDur; |
| if (parseFloat(durationInput.value) > maxDur) durationInput.value = maxDur; |
| } |
| |
| document.getElementById('imageFile').addEventListener('change', syncResolutionToImage); |
| document.getElementById('highRes').addEventListener('change', syncResolutionToImage); |
| |
| |
| window.addEventListener('DOMContentLoaded', syncResolutionToImage); |
| |
| |
| function getComponentType(config, id) { |
| const comp = (config.components || []).find(c => c.id === id); |
| return comp ? (comp.type || "").toLowerCase() : null; |
| } |
| |
| |
| |
| |
| |
| |
| async function resolveFnIndex(base) { |
| const manual = document.getElementById('fnIndexOverride').value.trim(); |
| if (manual !== "") return parseInt(manual, 10); |
| |
| |
| try { |
| const infoRes = await fetch(base + "/gradio_api/info", { headers: authHeaders() }); |
| if (infoRes.ok) { |
| const info = await infoRes.json(); |
| const unnamed = info.unnamed_endpoints || {}; |
| const infoCandidates = []; |
| for (const key in unnamed) { |
| const ep = unnamed[key]; |
| const params = ep.parameters || []; |
| if (params.length !== 10) continue; |
| const firstComp = (params[0].component || params[0].type?.type || "").toLowerCase(); |
| const returns = ep.returns || []; |
| const returnComps = returns.map(r => (r.component || r.type?.type || "").toLowerCase()); |
| if (firstComp.includes("image") && returnComps.some(t => t.includes("video"))) { |
| infoCandidates.push(parseInt(key, 10)); |
| } |
| } |
| if (infoCandidates.length === 1) return infoCandidates[0]; |
| } |
| } catch (e) { |
| |
| } |
| |
| |
| const cfgRes = await fetch(base + "/config", { headers: authHeaders() }); |
| if (!cfgRes.ok) { |
| throw new Error(`خطا در خواندن /config: HTTP ${cfgRes.status} ${cfgRes.statusText} — ${await safeText(cfgRes)}`); |
| } |
| const config = await cfgRes.json(); |
| |
| const byApiName = config.dependencies.find(d => d.api_name === "generate_video" || d.api_name === "/generate_video"); |
| if (byApiName) return byApiName.id; |
| |
| const exactLen = config.dependencies.filter(d => Array.isArray(d.inputs) && d.inputs.length === 10); |
| const refined = exactLen.filter(d => { |
| const firstType = getComponentType(config, d.inputs[0]); |
| const outputTypes = (d.outputs || []).map(id => getComponentType(config, id)); |
| return firstType === "image" && outputTypes.includes("video"); |
| }); |
| |
| const finalCandidates = refined.length > 0 ? refined : exactLen; |
| |
| if (finalCandidates.length === 1) return finalCandidates[0].id; |
| |
| if (finalCandidates.length === 0) { |
| throw new Error( |
| "هیچ اندپوینتی با ۱۰ ورودی و خروجی ویدیو در /config یا /gradio_api/info پیدا نشد. " + |
| "لطفاً fn_index را دستی وارد کنید (با باز کردن " + base + "/config در مرورگر و جستوجوی generate_video در دپندنسیها)."); |
| } |
| |
| const ids = finalCandidates.map(d => d.id).join(", "); |
| throw new Error( |
| `چند اندپوینت مشابه پیدا شد (id ها: ${ids}) و تشخیص خودکار نتوانست یکی را قطعی انتخاب کند. ` + |
| `لطفاً یکی از این مقادیر را در فیلد «fn_index دستی» وارد کنید و دوباره تلاش کنید.` |
| ); |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function resolveToggleFnIndexCandidates(base) { |
| const manual = document.getElementById('fnIndexToggleOverride').value.trim(); |
| if (manual !== "") return [parseInt(manual, 10)]; |
| |
| const cfgRes = await fetch(base + "/config", { headers: authHeaders() }); |
| if (!cfgRes.ok) { |
| throw new Error(`خطا در خواندن /config: HTTP ${cfgRes.status} ${cfgRes.statusText} — ${await safeText(cfgRes)}`); |
| } |
| const config = await cfgRes.json(); |
| const ordered = []; |
| const seen = new Set(); |
| const push = (id) => { if (id != null && !seen.has(id)) { seen.add(id); ordered.push(id); } }; |
| |
| |
| |
| |
| for (const d of config.dependencies) { |
| const name = (d.api_name || "").replace(/^\//, ""); |
| if (name === "on_highres_toggle" || name === "on_image_upload") push(d.id); |
| } |
| |
| |
| |
| |
| |
| const checkboxIds = (config.components || []) |
| .filter(c => (c.props?.label || "").includes("کیفیت بالا") || (c.type || "").toLowerCase() === "checkbox") |
| .map(c => c.id); |
| for (const d of config.dependencies) { |
| const targets = d.targets || []; |
| const targetIds = targets.map(t => Array.isArray(t) ? t[0] : t); |
| const trigger = d.trigger || (Array.isArray(targets[0]) ? targets[0][1] : null); |
| const triggersOnChange = trigger ? trigger === "change" : true; |
| if (triggersOnChange && targetIds.some(id => checkboxIds.includes(id))) push(d.id); |
| } |
| |
| |
| |
| const exactLen = config.dependencies.filter(d => Array.isArray(d.inputs) && d.inputs.length === 3 && Array.isArray(d.outputs) && d.outputs.length === 3); |
| const refined = exactLen.filter(d => { |
| const firstType = getComponentType(config, d.inputs[0]); |
| const secondType = getComponentType(config, d.inputs[1]); |
| return firstType === "image" && secondType === "checkbox"; |
| }); |
| for (const d of (refined.length > 0 ? refined : exactLen)) push(d.id); |
| |
| if (ordered.length === 0) { |
| throw new Error( |
| "اندپوینت بهروزرسانی وضوح/مدت (on_image_upload / on_highres_toggle) در /config پیدا نشد. " + |
| "لطفاً " + base + "/config را در مرورگر باز کنید، دنبال api_name های on_highres_toggle یا on_image_upload بگردید و id آن را در فیلد «fn_index دستی بهروزرسانی وضوح/مدت» وارد کنید." |
| ); |
| } |
| return ordered; |
| } |
| |
| |
| async function uploadImage(base, file) { |
| const form = new FormData(); |
| form.append("files", file); |
| const res = await fetch(base + "/gradio_api/upload", { |
| method: "POST", |
| headers: authHeaders(), |
| body: form |
| }); |
| if (!res.ok) { |
| throw new Error(`خطا در آپلود تصویر: HTTP ${res.status} ${res.statusText} — ${await safeText(res)}`); |
| } |
| const paths = await res.json(); |
| return { |
| path: paths[0], |
| orig_name: file.name, |
| meta: { _type: "gradio.FileData" } |
| }; |
| } |
| |
| |
| async function joinQueue(base, fnIndex, data, session_hash) { |
| const res = await fetch(base + "/gradio_api/queue/join", { |
| method: "POST", |
| headers: { "Content-Type": "application/json", ...authHeaders() }, |
| body: JSON.stringify({ data, fn_index: fnIndex, session_hash }) |
| }); |
| if (!res.ok) { |
| throw new Error(`خطا در پیوستن به صف: HTTP ${res.status} ${res.statusText} — ${await safeText(res)}`); |
| } |
| return res.json(); |
| } |
| |
| |
| |
| function streamResult(base, session_hash, onEvent) { |
| return new Promise((resolve, reject) => { |
| const es = new EventSource(base + "/gradio_api/queue/data?session_hash=" + session_hash); |
| es.onmessage = (e) => { |
| let msg; |
| try { |
| msg = JSON.parse(e.data); |
| } catch (err) { |
| return; |
| } |
| onEvent(msg); |
| |
| if (msg.msg === "process_completed") { |
| es.close(); |
| resolve(msg); |
| } else if (msg.msg === "unexpected_error") { |
| es.close(); |
| reject(new Error("خطای غیرمنتظره سمت سرور: " + (msg.message || JSON.stringify(msg)))); |
| } |
| }; |
| es.onerror = () => { |
| const state = es.readyState; |
| es.close(); |
| reject(new Error( |
| "اتصال جریان داده (SSE) قطع شد یا برقرار نشد (readyState=" + state + "). " + |
| "ممکن است session_hash نامعتبر باشد، اسپیس در حال sleep باشد، یا CORS مسدود کرده باشد." |
| )); |
| }; |
| }); |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function syncDurationWithServer(base, imageData, highRes, duration, session_hash) { |
| const candidates = await resolveToggleFnIndexCandidates(base); |
| const errors = []; |
| |
| for (const fnIndex of candidates) { |
| try { |
| |
| |
| |
| |
| await joinQueue(base, fnIndex, [imageData, highRes, duration], session_hash); |
| const msg = await streamResult(base, session_hash, () => {}); |
| if (msg.success === false) { |
| const detail = msg.output?.error || msg.error || JSON.stringify(msg.output ?? msg); |
| errors.push(`fn_index=${fnIndex}: ${detail}`); |
| continue; |
| } |
| const outputs = (msg.output?.data || []).map(unwrapValue); |
| if (outputs.length === 3 && typeof outputs[2] === "number") { |
| return { width: outputs[0], height: outputs[1], duration: outputs[2], fnIndex }; |
| } |
| errors.push(`fn_index=${fnIndex}: خروجی غیرمنتظره — ${JSON.stringify(outputs)}`); |
| } catch (e) { |
| errors.push(`fn_index=${fnIndex}: ${e.message}`); |
| } |
| } |
| |
| throw new Error("هیچکدام از اندپوینتهای نامزد کار نکردند:\n" + errors.join("\n")); |
| } |
| |
| async function generate() { |
| const btn = document.getElementById('genBtn'); |
| const barwrap = document.getElementById('barwrap'); |
| const bar = document.getElementById('bar'); |
| const video = document.getElementById('outVideo'); |
| video.style.display = 'none'; |
| barwrap.style.display = 'block'; |
| bar.style.width = '5%'; |
| btn.disabled = true; |
| |
| try { |
| const base = document.getElementById('spaceUrl').value.replace(/\/$/, ''); |
| const prompt = document.getElementById('prompt').value.trim(); |
| if (!prompt) throw new Error("لطفاً پرامپت را وارد کنید."); |
| |
| let duration = parseFloat(document.getElementById('duration').value); |
| const seed = parseInt(document.getElementById('seed').value, 10); |
| const randomizeSeed = document.getElementById('randomizeSeed').checked; |
| const enhancePrompt = document.getElementById('enhancePrompt').checked; |
| const highRes = document.getElementById('highRes').checked; |
| let width = parseInt(document.getElementById('width').value, 10); |
| let height = parseInt(document.getElementById('height').value, 10); |
| const imageInput = document.getElementById('imageFile'); |
| |
| setStatus("در حال یافتن اندپوینت API..."); |
| const fnIndex = await resolveFnIndex(base); |
| |
| let imageData = null; |
| if (imageInput.files.length > 0) { |
| setStatus("در حال آپلود تصویر..."); |
| imageData = await uploadImage(base, imageInput.files[0]); |
| } |
| |
| |
| |
| |
| |
| |
| const session_hash = sessionHash(); |
| |
| |
| |
| |
| if (duration > 15) { |
| setStatus("در حال همگامسازی وضوح/مدت با سرور (لازم چون بیشتر از ۱۵ ثانیه است)..."); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const safeDurationForSync = Math.min(duration, 15); |
| const synced = await syncDurationWithServer(base, imageData, highRes, safeDurationForSync, session_hash); |
| |
| |
| width = synced.width; document.getElementById('width').value = width; |
| height = synced.height; document.getElementById('height').value = height; |
| |
| |
| |
| |
| } |
| |
| |
| |
| |
| const data = [ |
| imageData, prompt, duration, enhancePrompt, |
| seed, randomizeSeed, height, width, |
| "free", "0" |
| ]; |
| |
| setStatus("در حال پیوستن به صف پردازش..."); |
| await joinQueue(base, fnIndex, data, session_hash); |
| |
| bar.style.width = '20%'; |
| const finalMsg = await streamResult(base, session_hash, (msg) => { |
| if (msg.msg === "estimation") { |
| setStatus("در صف انتظار... موقعیت: " + (msg.rank ?? "?")); |
| } else if (msg.msg === "process_starts") { |
| setStatus("در حال رندر ویدیو (ممکن است کمی طول بکشد)..."); |
| bar.style.width = '50%'; |
| } else if (msg.msg === "progress") { |
| bar.style.width = '75%'; |
| } |
| }); |
| |
| if (finalMsg.success === false) { |
| const detail = finalMsg.output?.error |
| || finalMsg.error |
| || (finalMsg.output ? JSON.stringify(finalMsg.output) : JSON.stringify(finalMsg)); |
| throw new Error("پردازش با خطا مواجه شد — جزئیات سرور: " + detail); |
| } |
| |
| const outputs = finalMsg.output?.data; |
| if (!outputs || !outputs[0]) { |
| throw new Error( |
| "خروجی ویدیو در پاسخ سرور یافت نشد (احتمالاً پرامپت رد شده یا محدودیت IP اعمال شده). " + |
| "پاسخ کامل سرور: " + JSON.stringify(finalMsg.output ?? finalMsg) |
| ); |
| } |
| |
| const videoFile = outputs[0]; |
| let videoUrl = videoFile.url || (videoFile.path ? base + "/gradio_api/file=" + videoFile.path : null); |
| if (!videoUrl) throw new Error("آدرس ویدیوی خروجی قابل استخراج نبود."); |
| |
| bar.style.width = '100%'; |
| video.src = videoUrl; |
| video.style.display = 'block'; |
| setStatus("ویدیو با موفقیت تولید شد ✅ (Seed: " + (outputs[1] ?? seed) + ")", "ok"); |
| |
| } catch (err) { |
| console.error(err); |
| setStatus("❌ خطا: " + (err && err.message ? err.message : String(err)), "err"); |
| } finally { |
| btn.disabled = false; |
| setTimeout(() => { barwrap.style.display = 'none'; bar.style.width = '0%'; }, 1500); |
| } |
| } |
| </script> |
| </body> |
| </html> |