vidltx2.3 / index.html
Opera10's picture
Update index.html
fbf90c5 verified
Raw
History Blame Contribute Delete
26.8 kB
<!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>
// Mirrors the RESOLUTIONS dict in app.py
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 } : {};
}
// Reads a response body as text for detailed error messages, without
// throwing if the body can't be read.
async function safeText(res) {
try { return await res.text(); } catch (e) { return "(بدون بدنه پاسخ)"; }
}
// Some Gradio versions return component updates as plain values, others as
// {"value": ..., "__type__": "update"} objects. Normalize both shapes.
function unwrapValue(v) {
if (v && typeof v === 'object' && '__type__' in v && 'value' in v) return v.value;
return v;
}
// Mirrors detect_aspect_ratio() in app.py: picks the closest of 16:9, 9:16, 1:1
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;
}
// Loads an image file and returns its natural width/height
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;
});
}
// Local (client-side only) mirror of on_image_upload()/on_highres_toggle().
// Used for instant UI feedback and as a fallback if the server-side sync
// call (syncDurationWithServer) can't be resolved for some reason.
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);
// Run once on load so the "کیفیت بالا" default (checked) and the duration
// max attribute agree with each other from the very first render.
window.addEventListener('DOMContentLoaded', syncResolutionToImage);
// Reads a component's type from /config given its id
function getComponentType(config, id) {
const comp = (config.components || []).find(c => c.id === id);
return comp ? (comp.type || "").toLowerCase() : null;
}
// Discover the fn_index for the generate_video endpoint.
// Strategy 1: /gradio_api/info — match by parameter/return component types.
// Strategy 2: /config dependencies — match by exact input count + component types.
// If more than one candidate remains, throw a detailed error listing all
// candidates so the user can fill in the manual override field.
async function resolveFnIndex(base) {
const manual = document.getElementById('fnIndexOverride').value.trim();
if (manual !== "") return parseInt(manual, 10);
// --- Strategy 1: /gradio_api/info ---
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) {
// /gradio_api/info not available on this Gradio version — fall through to /config
}
// --- Strategy 2: /config dependencies ---
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 دستی» وارد کنید و دوباره تلاش کنید.`
);
}
// Discover CANDIDATE fn_index values for on_image_upload / on_highres_toggle
// (both share the exact same signature — inputs=[input_image, high_res,
// duration], outputs=[width, height, duration]). We deliberately return a
// LIST, ranked by confidence, instead of blindly trusting the first shape
// match: shape-based matching (3 in / 3 out, image+checkbox) can collide
// with unrelated dependencies, which is what silently broke the previous
// version of this file. syncDurationWithServer() below tries each candidate
// in turn until one actually works.
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); } };
// Strategy A: exact api_name match (Gradio auto-derives api_name from the
// Python function name — on_image_upload / on_highres_toggle — unless it
// was explicitly disabled).
for (const d of config.dependencies) {
const name = (d.api_name || "").replace(/^\//, "");
if (name === "on_highres_toggle" || name === "on_image_upload") push(d.id);
}
// Strategy B: find the actual "کیفیت بالا" checkbox component by its label,
// then find whichever dependency is triggered by THAT component's
// "change" event. This is the most reliable signal since it doesn't rely
// on guessing input/output shapes at all.
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; // some configs omit trigger name
if (triggersOnChange && targetIds.some(id => checkboxIds.includes(id))) push(d.id);
}
// Strategy C: shape-based fallback (3 inputs / 3 outputs, first=image,
// second=checkbox) — collect ALL matches, not just the first.
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;
}
// 2. Upload the image file (if any) and return a Gradio FileData object
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(); // e.g. ["tmp/xxxx/file.jpg"]
return {
path: paths[0],
orig_name: file.name,
meta: { _type: "gradio.FileData" }
};
}
// 3. Join the queue
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();
}
// 4. Stream results until completion, surfacing the exact server-side error
// message when generation fails instead of a generic "خطا در ساخت".
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; // non-JSON keepalive line
}
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; // 0=connecting, 1=open, 2=closed
es.close();
reject(new Error(
"اتصال جریان داده (SSE) قطع شد یا برقرار نشد (readyState=" + state + "). " +
"ممکن است session_hash نامعتبر باشد، اسپیس در حال sleep باشد، یا CORS مسدود کرده باشد."
));
};
});
}
// Replays the exact same event the real Space UI fires when you check/uncheck
// "کیفیت بالا" (high_res.change → on_highres_toggle) or upload an image
// (input_image.change → on_image_upload), using the SAME session_hash that
// generate_video will use right after. This is what actually raises the
// duration Slider's allowed maximum from 15 to 35 for a low-res generation —
// simply sending duration=20 in the generate_video payload without this step
// is not enough, because the Slider component's max is still the default 15
// declared in app.py for a session that never triggered that event.
async function syncDurationWithServer(base, imageData, highRes, duration, session_hash) {
const candidates = await resolveToggleFnIndexCandidates(base);
const errors = [];
for (const fnIndex of candidates) {
try {
// Deliberately reuse the SAME session_hash that generate_video will be
// called with right after — the whole point of this call is to update
// server-side state *for that specific session*, so a different
// session_hash here would accomplish nothing.
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]);
}
// Use ONE session_hash for both the sync call and the generate call so
// any server-side component state the sync call updates (in particular
// the duration Slider's maximum, which is exactly why >15s failed
// regardless of the high-res toggle before) is in effect when
// generate_video runs.
const session_hash = sessionHash();
// Only bother with the sync round-trip when duration actually needs a
// bound above the server's static default (>15s) — for anything <=15s
// this step is unnecessary and skipping it is one less thing to fail.
if (duration > 15) {
setStatus("در حال همگام‌سازی وضوح/مدت با سرور (لازم چون بیشتر از ۱۵ ثانیه است)...");
// IMPORTANT: this call must send a duration that is ALREADY within the
// server's *current* bound (still 15 at this point), exactly like the
// real UI does — the toggle fires with whatever the slider currently
// shows (e.g. 10), THEN the bound becomes 35, and only THEN does the
// user drag it up to 30 as a separate step. Sending the desired final
// value (30) here gets rejected by the sync call's OWN input
// validation before it ever has a chance to raise the bound — which
// is exactly the error seen: it failed on the sync call itself, not
// on generate_video.
const safeDurationForSync = Math.min(duration, 15);
const synced = await syncDurationWithServer(base, imageData, highRes, safeDurationForSync, session_hash);
// width/height from the sync response are accurate (aspect+tier based,
// unaffected by the duration placeholder above) — use them.
width = synced.width; document.getElementById('width').value = width;
height = synced.height; document.getElementById('height').value = height;
// Do NOT take `duration` from synced here — it only reflects the safe
// placeholder we just sent (e.g. 10), not the value the user actually
// wants. Keep the user's original `duration` (e.g. 30); the server's
// bound should now be raised to 35 so it will be accepted.
}
// Order matches the Python click handler's `inputs=[...]` list:
// input_image, prompt, duration, enhance_prompt, seed, randomize_seed,
// height, width, user_tier, last_gen_time
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>