| <!doctype html> |
| <html lang="fa" dir="rtl"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> |
| <meta name="theme-color" content="#090a12"> |
| <title>TGS Converter Pro — TGS → GIF / MP4 / WebM</title> |
| <style> |
| :root{ |
| --bg:#080910;--panel:rgba(19,20,34,.78);--panel2:rgba(26,27,45,.72); |
| --text:#f5f6ff;--muted:#9ea3bd;--line:rgba(255,255,255,.09); |
| --accent:#8b5cf6;--accent2:#6366f1;--good:#34d399;--bad:#fb7185; |
| --shadow:0 24px 80px rgba(0,0,0,.38);--r:24px; |
| } |
| [data-theme=light]{--bg:#f4f5fb;--panel:rgba(255,255,255,.82);--panel2:#fff;--text:#171827;--muted:#62677d;--line:rgba(20,20,40,.1);--shadow:0 24px 70px rgba(50,45,90,.15)} |
| *{box-sizing:border-box} |
| html{scroll-behavior:smooth} |
| body{margin:0;min-height:100vh;background: |
| radial-gradient(900px 500px at 85% -10%,rgba(139,92,246,.22),transparent 60%), |
| radial-gradient(800px 500px at 5% 10%,rgba(99,102,241,.16),transparent 55%),var(--bg); |
| color:var(--text);font-family:system-ui,-apple-system,"Segoe UI",Tahoma,Arial,sans-serif} |
| button,input,select{font:inherit} |
| button{cursor:pointer} |
| .app{max-width:1250px;margin:auto;padding:24px 16px 50px} |
| .top{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:25px} |
| .brand{display:flex;align-items:center;gap:13px} |
| .logo{width:50px;height:50px;border-radius:17px;display:grid;place-items:center;font-weight:900; |
| background:linear-gradient(135deg,#8b5cf6,#4f46e5);box-shadow:0 12px 35px rgba(99,102,241,.35);font-size:14px} |
| h1{font-size:20px;margin:0}.sub{color:var(--muted);font-size:12px;margin-top:3px} |
| .actions{display:flex;gap:8px}.icon-btn,.ghost{border:1px solid var(--line);background:var(--panel);color:var(--text);border-radius:14px;padding:10px 13px;backdrop-filter:blur(16px)} |
| .hero{padding:34px 22px;border:1px solid var(--line);border-radius:32px;background:linear-gradient(135deg,rgba(139,92,246,.15),rgba(99,102,241,.06) 45%,var(--panel)); |
| box-shadow:var(--shadow);overflow:hidden;position:relative} |
| .hero:after{content:"";position:absolute;width:240px;height:240px;border-radius:50%;background:rgba(139,92,246,.14);filter:blur(50px);left:-80px;bottom:-120px} |
| .hero h2{font-size:clamp(28px,5vw,52px);line-height:1.05;margin:0 0 13px;font-weight:900;letter-spacing:-1px} |
| .grad{background:linear-gradient(90deg,#c4b5fd,#818cf8,#a78bfa);-webkit-background-clip:text;background-clip:text;color:transparent} |
| .hero p{max-width:750px;color:var(--muted);line-height:1.8;margin:0 0 22px} |
| .drop{position:relative;z-index:1;border:1.5px dashed rgba(167,139,250,.6);border-radius:24px;padding:30px 16px;text-align:center;background:rgba(255,255,255,.025);transition:.2s} |
| .drop.over,.drop:hover{background:rgba(139,92,246,.09);border-color:#a78bfa;transform:translateY(-1px)} |
| .drop-icon{font-size:42px;margin-bottom:7px}.drop strong{display:block;font-size:17px}.drop small{display:block;color:var(--muted);margin:7px 0 16px} |
| .primary{border:0;border-radius:14px;padding:12px 19px;color:white;font-weight:800;background:linear-gradient(135deg,#8b5cf6,#4f46e5);box-shadow:0 12px 30px rgba(99,102,241,.28)} |
| .primary:disabled,.secondary:disabled{opacity:.45;cursor:not-allowed} |
| .layout{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);gap:18px;margin-top:18px} |
| .card{border:1px solid var(--line);border-radius:var(--r);background:var(--panel);backdrop-filter:blur(18px);box-shadow:var(--shadow);padding:18px} |
| .card-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:15px;font-weight:850} |
| .badge{font-size:11px;padding:5px 9px;border-radius:999px;background:rgba(139,92,246,.13);color:#c4b5fd} |
| .preview-wrap{aspect-ratio:1/1;max-height:620px;border-radius:20px;overflow:hidden;display:grid;place-items:center; |
| background-color:#10111b;background-image:linear-gradient(45deg,#1b1c28 25%,transparent 25%),linear-gradient(-45deg,#1b1c28 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#1b1c28 75%),linear-gradient(-45deg,transparent 75%,#1b1c28 75%); |
| background-size:28px 28px;background-position:0 0,0 14px,14px -14px,-14px 0} |
| #previewCanvas{width:100%;height:100%;object-fit:contain;display:block} |
| .empty{color:var(--muted);text-align:center;padding:40px}.filebar{display:flex;align-items:center;gap:10px;margin-top:12px} |
| .filebar .name{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px} |
| .controls{display:flex;gap:8px;align-items:center;margin-top:12px;flex-wrap:wrap} |
| .secondary{border:1px solid var(--line);background:var(--panel2);color:var(--text);border-radius:12px;padding:9px 12px} |
| .range{flex:1;min-width:120px}.speed{display:flex;gap:5px}.speed button{padding:7px 9px;font-size:12px} |
| .info-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:12px} |
| .stat{padding:10px;border:1px solid var(--line);border-radius:13px;background:rgba(255,255,255,.025)}.stat b{display:block;font-size:15px}.stat span{color:var(--muted);font-size:10px} |
| .section{padding:14px 0;border-top:1px solid var(--line)}.section:first-of-type{border-top:0} |
| label{display:block;font-size:12px;color:var(--muted);margin-bottom:6px} |
| .row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px} |
| input[type=text],input[type=number],select{width:100%;border:1px solid var(--line);background:rgba(0,0,0,.15);color:var(--text);border-radius:12px;padding:10px 11px;outline:none} |
| input:focus,select:focus{border-color:#8b5cf6;box-shadow:0 0 0 3px rgba(139,92,246,.12)} |
| input[type=range]{accent-color:#8b5cf6} |
| .check{display:flex;align-items:center;gap:8px;color:var(--text);font-size:12px;margin:8px 0}.check input{accent-color:#8b5cf6} |
| .formats{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}.fmt{border:1px solid var(--line);background:rgba(255,255,255,.025);border-radius:13px;padding:12px;text-align:right;color:var(--text);transition:.15s}.fmt:hover{border-color:rgba(167,139,250,.5)}.fmt.active{border-color:#8b5cf6;background:rgba(139,92,246,.12);box-shadow:inset 0 0 0 1px rgba(139,92,246,.18)}.fmt b{display:block;font-size:13px}.fmt span{display:block;color:var(--muted);font-size:10px;margin-top:3px} |
| .bgopts{display:flex;gap:8px;flex-wrap:wrap}.bgopt{border:1px solid var(--line);padding:8px 10px;border-radius:10px;background:transparent;color:var(--text);font-size:11px}.bgopt.active{border-color:#8b5cf6;background:rgba(139,92,246,.12)} |
| .color{width:48px;height:38px;padding:3px} |
| .convert{width:100%;font-size:15px;margin-top:8px} |
| .progress{margin-top:13px;display:none}.progress.show{display:block}.progress-top{display:flex;justify-content:space-between;color:var(--muted);font-size:11px;margin-bottom:6px}.bar{height:8px;background:rgba(255,255,255,.08);border-radius:99px;overflow:hidden}.bar i{display:block;width:0;height:100%;background:linear-gradient(90deg,#8b5cf6,#818cf8);transition:width .12s} |
| .result{margin-top:12px;display:none;padding:12px;border-radius:14px;border:1px solid rgba(52,211,153,.25);background:rgba(52,211,153,.06)}.result.show{display:flex;align-items:center;gap:10px}.result a{margin-inline-start:auto;color:#a7f3d0;text-decoration:none;font-weight:800} |
| .toast{position:fixed;z-index:99;bottom:18px;left:50%;transform:translate(-50%,20px);opacity:0;pointer-events:none;padding:11px 15px;border-radius:13px;background:#171827;color:#fff;border:1px solid var(--line);box-shadow:var(--shadow);transition:.25s;font-size:12px}.toast.show{opacity:1;transform:translate(-50%,0)} |
| .queue{margin-top:18px;display:none}.queue.show{display:block}.qitem{display:flex;align-items:center;gap:10px;padding:10px;border-top:1px solid var(--line)}.qitem:first-child{border-top:0}.qname{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12px}.qstatus{font-size:10px;color:var(--muted)} |
| footer{text-align:center;color:var(--muted);font-size:11px;padding:25px 10px}.kbd{border:1px solid var(--line);border-radius:6px;padding:2px 5px} |
| .hidden{display:none!important} |
| @media(max-width:850px){.layout{grid-template-columns:1fr}.hero h2{font-size:34px}.app{padding:14px 10px 40px}.top{margin-bottom:14px}} |
| @media(max-width:520px){.row{grid-template-columns:1fr}.formats{grid-template-columns:1fr}.info-grid{grid-template-columns:repeat(3,1fr)}.hero{padding:25px 15px}.card{padding:14px}} |
| @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}} |
| </style> |
| </head> |
| <body> |
| <div class="app"> |
| <header class="top"> |
| <div class="brand"><div class="logo">TGS→</div><div><h1>TGS Converter Pro</h1><div class="sub">Lottie / Telegram Animated Sticker Studio</div></div></div> |
| <div class="actions"><button class="icon-btn" id="themeBtn" title="تغییر پوسته">☼</button></div> |
| </header> |
|
|
| <section class="hero"> |
| <h2>TGS را به <span class="grad">GIF، MP4، WebM</span><br>و تصویر تبدیل کن.</h2> |
| <p>رندر با Canvas در مرورگر خودت؛ فایلها روی سرور آپلود نمیشوند. برای TGS، Lottie JSON و Lottie .lottie طراحی شده است.</p> |
| <div class="drop" id="drop"> |
| <div class="drop-icon">✦</div> |
| <strong>فایل را اینجا رها کن</strong> |
| <small>.tgs · .json · .lottie — چند فایل همزمان قابل انتخاب است</small> |
| <button class="primary" id="pickBtn">انتخاب فایل</button> |
| <input class="hidden" id="fileInput" type="file" multiple accept=".tgs,.json,.lottie,application/gzip,application/json"> |
| </div> |
| </section> |
|
|
| <div class="layout"> |
| <section class="card"> |
| <div class="card-title"><span>پیشنمایش</span><span class="badge" id="parseBadge">منتظر فایل</span></div> |
| <div class="preview-wrap" id="previewWrap"> |
| <canvas id="previewCanvas" width="512" height="512"></canvas> |
| <div class="empty" id="empty">فایل TGS را انتخاب کن تا پیشنمایش اینجا ظاهر شود ✨</div> |
| </div> |
| <div class="filebar hidden" id="filebar"><span>📦</span><span class="name" id="fileName"></span><button class="secondary" id="clearBtn">حذف</button></div> |
| <div class="controls"> |
| <button class="secondary" id="playBtn" disabled>▶ پخش</button> |
| <button class="secondary" id="stepBtn" disabled>⏭ فریم</button> |
| <input class="range" id="frameRange" type="range" min="0" max="0" value="0" disabled> |
| <div class="speed"> |
| <button class="secondary spd" data-speed=".5">0.5×</button> |
| <button class="secondary spd active" data-speed="1">1×</button> |
| <button class="secondary spd" data-speed="2">2×</button> |
| </div> |
| </div> |
| <div class="info-grid"> |
| <div class="stat"><b id="statFrames">—</b><span>فریم</span></div> |
| <div class="stat"><b id="statFps">—</b><span>FPS</span></div> |
| <div class="stat"><b id="statDuration">—</b><span>مدت</span></div> |
| </div> |
| <div class="queue" id="queue"><div class="card-title">صف تبدیل <span class="badge" id="queueCount">0</span></div><div id="queueList"></div></div> |
| </section> |
|
|
| <aside class="card"> |
| <div class="card-title">تنظیمات خروجی <span class="badge">PRO</span></div> |
| <div class="section"> |
| <label>فرمت خروجی</label> |
| <div class="formats" id="formats"> |
| <button class="fmt active" data-format="gif"><b>🎞 GIF</b><span>شفاف + Loop</span></button> |
| <button class="fmt" data-format="webm"><b>▶ WebM</b><span>ویدئوی سبک</span></button> |
| <button class="fmt" data-format="mp4"><b>🎬 MP4</b><span>H.264 / fallback</span></button> |
| <button class="fmt" data-format="zip"><b>🗂 PNG ZIP</b><span>تمام فریمها</span></button> |
| <button class="fmt" data-format="png"><b>🖼 PNG</b><span>یک فریم</span></button> |
| <button class="fmt" data-format="webp"><b>🌐 WebP</b><span>یک فریم</span></button> |
| </div> |
| </div> |
| <div class="section"> |
| <div class="row"> |
| <div><label>عرض (px)</label><input id="width" type="number" min="16" max="2048" value="512"></div> |
| <div><label>ارتفاع (px)</label><input id="height" type="number" min="16" max="2048" value="512"></div> |
| </div> |
| <label class="check"><input id="preserve" type="checkbox" checked> حفظ نسبت تصویر</label> |
| <div class="row"> |
| <div><label>FPS</label><input id="fps" type="number" min="1" max="60" value="60"></div> |
| <div><label>کیفیت / بیتریت</label><select id="quality"><option value="low">اقتصادی</option><option value="medium" selected>متعادل</option><option value="high">بالا</option><option value="max">حداکثر</option></select></div> |
| </div> |
| </div> |
| <div class="section"> |
| <label>پسزمینه</label> |
| <div class="bgopts"> |
| <button class="bgopt active" data-bg="transparent">شفاف</button> |
| <button class="bgopt" data-bg="checker">شطرنجی</button> |
| <button class="bgopt" data-bg="solid">رنگ</button> |
| <input class="color" id="bgColor" type="color" value="#ffffff" title="رنگ پسزمینه"> |
| </div> |
| </div> |
| <div class="section"> |
| <div class="row"> |
| <div><label>شروع فریم</label><input id="start" type="number" min="0" value="0"></div> |
| <div><label>پایان فریم</label><input id="end" type="number" min="0" value="0"></div> |
| </div> |
| <div class="row" id="gifSettings"> |
| <div><label>تعداد Loop</label><select id="loopCount"><option value="0">بینهایت</option><option value="1">۱ بار</option><option value="2">۲ بار</option><option value="3">۳ بار</option><option value="5">۵ بار</option></select></div> |
| <div><label>فریم تکتصویر</label><input id="singleFrame" type="number" min="0" value="0"></div> |
| </div> |
| </div> |
| <button class="primary convert" id="convertBtn" disabled>⚡ تبدیل و آمادهسازی دانلود</button> |
| <button class="secondary convert" id="cancelBtn" style="display:none">✕ لغو تبدیل</button> |
| <div class="progress" id="progress"><div class="progress-top"><span id="progressText">در حال آمادهسازی…</span><b id="progressPct">0%</b></div><div class="bar"><i id="progressBar"></i></div></div> |
| <div class="result" id="result"><span>✓</span><span id="resultText">آماده دانلود</span><a id="download" href="#">دانلود</a></div> |
| </aside> |
| </div> |
|
|
| <footer>🔒 تمام پردازشها بهصورت محلی در مرورگر انجام میشوند و فایلهای شما از دستگاه خارج نمیشوند.<br><span class="kbd">TGS</span> → Lottie JSON → Canvas → خروجی</footer> |
| </div> |
| <div class="toast" id="toast"></div> |
|
|
| <script src="https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js"></script> |
|
|
| <script> |
| "use strict"; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; |
| const els={ |
| drop:$("#drop"),pick:$("#pickBtn"),input:$("#fileInput"),canvas:$("#previewCanvas"),empty:$("#empty"), |
| filebar:$("#filebar"),name:$("#fileName"),clear:$("#clearBtn"),badge:$("#parseBadge"), |
| play:$("#playBtn"),step:$("#stepBtn"),range:$("#frameRange"),statFrames:$("#statFrames"), |
| statFps:$("#statFps"),statDuration:$("#statDuration"),convert:$("#convertBtn"),cancel:$("#cancelBtn"), |
| progress:$("#progress"),pbar:$("#progressBar"),pct:$("#progressPct"),ptext:$("#progressText"), |
| result:$("#result"),download:$("#download"),resultText:$("#resultText"),toast:$("#toast"), |
| width:$("#width"),height:$("#height"),preserve:$("#preserve"),fps:$("#fps"),quality:$("#quality"), |
| start:$("#start"),end:$("#end"),loop:$("#loopCount"),single:$("#singleFrame"),bgColor:$("#bgColor"), |
| previewWrap:$("#previewWrap"),gifSettings:$("#gifSettings") |
| }; |
| let state={file:null,json:null,anim:null,format:"gif",bg:"transparent",speed:1,playing:false,cancel:false, |
| objectUrl:null,resultUrl:null,ratio:1,frame:0,queue:[]}; |
| |
| function toast(msg){ |
| els.toast.textContent=msg;els.toast.classList.add("show"); |
| clearTimeout(toast.t);toast.t=setTimeout(()=>els.toast.classList.remove("show"),2600); |
| } |
| function setProgress(v,text){ |
| v=Math.max(0,Math.min(100,v));els.progress.classList.add("show"); |
| els.pbar.style.width=v+"%";els.pct.textContent=Math.round(v)+"%";els.ptext.textContent=text||""; |
| } |
| function ext(name){return (name.split(".").pop()||"").toLowerCase()} |
| function human(n){if(n<1024)return n+" B";if(n<1048576)return (n/1024).toFixed(1)+" KB";return (n/1048576).toFixed(2)+" MB"} |
| function sleep(ms){return new Promise(r=>setTimeout(r,ms))} |
| function nextFrame(){return new Promise(r=>requestAnimationFrame(()=>r()))} |
| function canvas2d(){ |
| const c=els.canvas,ctx=c.getContext("2d",{alpha:true,willReadFrequently:true}); |
| return ctx; |
| } |
| function configureCanvas(w,h){ |
| els.canvas.width=w;els.canvas.height=h; |
| els.canvas.style.aspectRatio=w+"/"+h; |
| } |
| function clearCanvas(){ |
| const c=els.canvas,ctx=canvas2d();ctx.clearRect(0,0,c.width,c.height); |
| } |
| function drawBackground(ctx,w,h,bg){ |
| if(bg==="transparent")return; |
| if(bg==="checker"){ |
| const s=Math.max(8,Math.round(Math.min(w,h)/24)); |
| for(let y=0;y<h;y+=s)for(let x=0;x<w;x+=s){ctx.fillStyle=((x/s+y/s)&1)?"#e7e7ed":"#ffffff";ctx.fillRect(x,y,s,s)} |
| }else{ctx.fillStyle=els.bgColor.value;ctx.fillRect(0,0,w,h)} |
| } |
| async function parseFile(file){ |
| const e=ext(file.name); |
| if(!["tgs","json","lottie"].includes(e))throw new Error("فرمت فایل پشتیبانی نمیشود."); |
| if(file.size>100*1024*1024)throw new Error("فایل بزرگتر از ۱۰۰ مگابایت است."); |
| setProgress(3,"خواندن فایل…"); |
| const buf=await file.arrayBuffer(); |
| let text; |
| if(e==="tgs"){ |
| try{text=new TextDecoder().decode(pako.ungzip(new Uint8Array(buf)))}catch(err){ |
| if("DecompressionStream" in window){ |
| const ds=new DecompressionStream("gzip"),stream=new Blob([buf]).stream().pipeThrough(ds); |
| text=await new Response(stream).text(); |
| }else throw new Error("فشردهسازی GZIP قابل باز کردن نیست."); |
| } |
| }else if(e==="lottie"){ |
| |
| if(typeof JSZip==="undefined")throw new Error("JSZip بارگذاری نشده است."); |
| const z=await JSZip.loadAsync(buf); |
| const candidates=Object.keys(z.files).filter(k=>/\.json$/i.test(k)&&!z.files[k].dir); |
| if(!candidates.length)throw new Error("داخل فایل .lottie فایل JSON پیدا نشد."); |
| const preferred=candidates.find(k=>/animations?\/|manifest/i.test(k))||candidates[0]; |
| text=await z.files[preferred].async("text"); |
| }else text=new TextDecoder().decode(new Uint8Array(buf)); |
| let json;try{json=JSON.parse(text)}catch(err){throw new Error("JSON/Lottie نامعتبر است.")} |
| if(!json||typeof json!=="object")throw new Error("ساختار Lottie نامعتبر است."); |
| return json; |
| } |
| function destroyAnim(){ |
| if(state.anim){try{state.anim.destroy()}catch(e){}state.anim=null} |
| } |
| async function loadLottie(json){ |
| destroyAnim();clearCanvas(); |
| const w=+json.w||512,h=+json.h||512; |
| configureCanvas(w,h);state.ratio=w/h; |
| state.anim=lottie.loadAnimation({container:els.canvas,renderer:"canvas",loop:false,autoplay:false,animationData:json,rendererSettings:{ |
| clearCanvas:true,preserveAspectRatio:"xMidYMid meet",progressiveLoad:false |
| }}); |
| await new Promise((resolve,reject)=>{let done=false; |
| const ok=()=>{if(!done){done=true;resolve()}};state.anim.addEventListener("DOMLoaded",ok); |
| setTimeout(()=>{if(!done){done=true;resolve()}},5000); |
| }); |
| const total=Math.max(1,Math.round(state.anim.getDuration(true))); |
| const ip=Number.isFinite(+json.ip)?+json.ip:0,op=Number.isFinite(+json.op)?+json.op:total; |
| const frames=Math.max(1,Math.round(op-ip)),fps=Number(json.fr)||60; |
| state.total=frames;state.ip=ip;state.op=op;state.fileFps=fps; |
| els.range.max=frames-1;els.range.value=0;els.start.max=frames-1;els.end.max=frames-1;els.end.value=frames-1; |
| els.single.max=frames-1;els.fps.value=Math.min(60,Math.max(1,Math.round(fps))); |
| els.statFrames.textContent=frames;els.statFps.textContent=fps;els.statDuration.textContent=(frames/fps).toFixed(2)+"s"; |
| els.play.disabled=els.step.disabled=els.range.disabled=els.convert.disabled=false; |
| els.empty.classList.add("hidden");els.badge.textContent="آماده"; |
| renderFrame(0);setProgress(100,"فایل آماده است");setTimeout(()=>els.progress.classList.remove("show"),700); |
| } |
| function renderFrame(n){ |
| if(!state.anim)return; |
| n=Math.max(0,Math.min((state.total||1)-1,Math.round(n)));state.frame=n; |
| try{state.anim.goToAndStop((state.ip||0)+n,true)}catch(e){state.anim.goToAndStop(n,true)} |
| els.range.value=n;els.single.value=n; |
| } |
| function play(){ |
| if(!state.anim)return; |
| if(state.playing){state.playing=false;els.play.textContent="▶ پخش";return} |
| state.playing=true;els.play.textContent="⏸ توقف"; |
| let last=performance.now(),acc=0; |
| const tick=now=>{ |
| if(!state.playing)return; |
| acc+=now-last;last=now; |
| const interval=1000/(Math.max(1,+els.fps.value||state.fileFps||60)*state.speed); |
| if(acc>=interval){acc%=interval;renderFrame((state.frame+1)%(state.total||1))} |
| requestAnimationFrame(tick); |
| };requestAnimationFrame(tick); |
| } |
| function getSettings(){ |
| let w=Math.max(16,Math.min(2048,+els.width.value||512)),h=Math.max(16,Math.min(2048,+els.height.value||512)); |
| if(els.preserve.checked){h=Math.max(16,Math.round(w/state.ratio));els.height.value=h} |
| let fps=Math.max(1,Math.min(60,+els.fps.value||60)); |
| let start=Math.max(0,Math.min((state.total||1)-1,+els.start.value||0)); |
| let end=Math.max(start,Math.min((state.total||1)-1,+els.end.value||((state.total||1)-1))); |
| return {w,h,fps,start,end,quality:els.quality.value,bg:state.bg}; |
| } |
| async function sampleFrame(n,s){ |
| renderFrame(n);await nextFrame();await nextFrame(); |
| const out=document.createElement("canvas");out.width=s.w;out.height=s.h; |
| const ctx=out.getContext("2d",{alpha:true,willReadFrequently:true}); |
| drawBackground(ctx,s.w,s.h,s.bg); |
| ctx.drawImage(els.canvas,0,0,s.w,s.h); |
| return out; |
| } |
| function mimeForImage(fmt){return fmt==="webp"?"image/webp":"image/png"} |
| function blobFromCanvas(c,type,quality){return new Promise((res,rej)=>c.toBlob(b=>b?res(b):rej(new Error("ساخت تصویر ناموفق بود.")),type,quality))} |
| async function convertSingle(fmt,s){ |
| const n=Math.min(s.end,Math.max(s.start,+els.single.value||state.frame)); |
| const c=await sampleFrame(n,s);setProgress(75,"ساخت تصویر…"); |
| const blob=await blobFromCanvas(c,mimeForImage(fmt),s.quality==="max"?1:s.quality==="high"?.95:.88); |
| return {blob,ext:fmt,name:safeName()+"_frame."+fmt}; |
| } |
| function safeName(){return (state.file?.name||"sticker").replace(/\.[^.]+$/,"").replace(/[\\/:*?"<>|]+/g,"_")} |
| async function convertZip(s){ |
| const zip=new JSZip(),count=s.end-s.start+1; |
| for(let i=0;i<count;i++){if(state.cancel)throw new Error("__CANCEL__"); |
| const n=s.start+i,c=await sampleFrame(n,s),b=await blobFromCanvas(c,"image/png"); |
| zip.file(String(i+1).padStart(5,"0")+".png",b); |
| setProgress(10+i/count*80,`رندر فریم ${i+1} از ${count}…`); |
| } |
| const blob=await zip.generateAsync({type:"blob",compression:"STORE"},m=>setProgress(90+m.percent/10,"ساخت ZIP…")); |
| return {blob,ext:"zip",name:safeName()+"_frames.zip"}; |
| } |
| function gifWorkerURL(){ |
| return "https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js"; |
| } |
| async function convertGif(s){ |
| if(typeof GIF==="undefined")throw new Error("GIF encoder بارگذاری نشده است."); |
| const count=s.end-s.start+1,delay=Math.max(1,Math.round(1000/s.fps)); |
| const gif=new GIF({workers:2,quality:s.quality==="max"?1:s.quality==="high"?3:8,workerScript:gifWorkerURL(),repeat:+els.loop.value||0,transparent:s.bg==="transparent"?"#000000":null}); |
| for(let i=0;i<count;i++){if(state.cancel)throw new Error("__CANCEL__"); |
| const c=await sampleFrame(s.start+i,s); |
| gif.addFrame(c,{copy:true,delay}); |
| setProgress(5+i/count*75,`افزودن فریم ${i+1} از ${count}…`); |
| } |
| const blob=await new Promise((resolve,reject)=>{gif.on("progress",p=>setProgress(80+p*20,"فشردهسازی GIF…"));gif.on("finished",resolve);gif.on("abort",()=>reject(new Error("GIF لغو شد.")));gif.render()}); |
| return {blob,ext:"gif",name:safeName()+".gif"}; |
| } |
| async function convertVideo(s,requested){ |
| const c=document.createElement("canvas");c.width=s.w;c.height=s.h; |
| const stream=c.captureStream(s.fps); |
| const candidates=requested==="mp4" |
| ? ["video/mp4;codecs=avc1.42E01E,mp4a.40.2","video/mp4;codecs=avc1","video/mp4"] |
| : ["video/webm;codecs=vp9","video/webm;codecs=vp8","video/webm"]; |
| let mime=candidates.find(x=>MediaRecorder.isTypeSupported(x)); |
| if(!mime){ |
| if(requested==="mp4")mime=["video/webm;codecs=vp9","video/webm"].find(x=>MediaRecorder.isTypeSupported(x)); |
| if(!mime)throw new Error("MediaRecorder و فرمت ویدئو در این مرورگر پشتیبانی نمیشود."); |
| } |
| const bitrate={low:1.5,medium:4,high:8,max:16}[s.quality]*1e6; |
| const rec=new MediaRecorder(stream,{mimeType:mime,videoBitsPerSecond:bitrate}); |
| const chunks=[];rec.ondataavailable=e=>e.data.size&&chunks.push(e.data); |
| const stopped=new Promise((resolve,reject)=>{rec.onstop=()=>resolve(new Blob(chunks,{type:mime}));rec.onerror=e=>reject(e.error||e)}); |
| rec.start(); |
| const count=s.end-s.start+1,frameMs=1000/s.fps; |
| for(let i=0;i<count;i++){if(state.cancel){rec.stop();throw new Error("__CANCEL__")} |
| const src=await sampleFrame(s.start+i,s),ctx=c.getContext("2d");ctx.clearRect(0,0,s.w,s.h);ctx.drawImage(src,0,0); |
| await sleep(frameMs);setProgress(5+i/count*90,`رندر ویدئو ${i+1} از ${count}…`); |
| } |
| rec.stop();const blob=await stopped; |
| const actual=mime.includes("mp4")?"mp4":"webm"; |
| return {blob,ext:actual,name:safeName()+"."+actual,requested}; |
| } |
| async function convert(){ |
| if(!state.anim)return; |
| state.cancel=false;els.convert.style.display="none";els.cancelBtn.style.display="block";els.result.classList.remove("show"); |
| try{ |
| const s=getSettings();let result; |
| setProgress(1,"شروع رندر…"); |
| if(state.format==="gif")result=await convertGif(s); |
| else if(state.format==="zip")result=await convertZip(s); |
| else if(state.format==="png"||state.format==="webp")result=await convertSingle(state.format,s); |
| else result=await convertVideo(s,state.format); |
| if(state.resultUrl)URL.revokeObjectURL(state.resultUrl); |
| state.resultUrl=URL.createObjectURL(result.blob);els.download.href=state.resultUrl;els.download.download=result.name; |
| els.resultText.textContent=result.requested&&result.ext!==result.requested |
| ? `این مرورگر MP4 واقعی تولید نکرد؛ خروجی WebM آماده است (${human(result.blob.size)}).` |
| : `خروجی آماده است (${human(result.blob.size)}).`; |
| els.result.classList.add("show");setProgress(100,"تکمیل شد ✓");toast("تبدیل با موفقیت انجام شد 🎉"); |
| }catch(e){ |
| if(e.message==="__CANCEL__")toast("تبدیل لغو شد."); |
| else {console.error(e);toast("خطا: "+(e.message||"تبدیل ناموفق بود."));} |
| }finally{els.convert.style.display="block";els.cancelBtn.style.display="none";state.cancel=false} |
| } |
| function saveSettings(){ |
| localStorage.setItem("tgspro",JSON.stringify({ |
| width:els.width.value,height:els.height.value,preserve:els.preserve.checked,fps:els.fps.value, |
| quality:els.quality.value,bg:state.bg,color:els.bgColor.value,loop:els.loop.value |
| })); |
| } |
| function loadSettings(){ |
| try{const s=JSON.parse(localStorage.getItem("tgspro")||"{}"); |
| for(const [k,v] of Object.entries(s)){if(k==="bg")state.bg=v;else if(k==="color")els.bgColor.value=v;else if(els[k])els[k].value=v} |
| if(typeof s.preserve==="boolean")els.preserve.checked=s.preserve; |
| }catch(e){} |
| } |
| function updateBgUI(){ |
| $$(".bgopt").forEach(b=>b.classList.toggle("active",b.dataset.bg===state.bg)); |
| els.previewWrap.style.backgroundColor=state.bg==="solid"?els.bgColor.value:"#10111b"; |
| els.previewWrap.style.backgroundImage=state.bg==="checker"?getComputedStyle(document.documentElement).getPropertyValue("--checker"):""; |
| if(state.bg==="checker")els.previewWrap.style.backgroundImage="linear-gradient(45deg,#1b1c28 25%,transparent 25%),linear-gradient(-45deg,#1b1c28 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#1b1c28 75%),linear-gradient(-45deg,transparent 75%,#1b1c28 75%)"; |
| else els.previewWrap.style.backgroundImage=""; |
| } |
| async function acceptFiles(files){ |
| if(!files.length)return; |
| state.queue=files.slice(); |
| renderQueue(); |
| await selectFile(files[0]); |
| } |
| function renderQueue(){ |
| if(state.queue.length<2){$("#queue").classList.remove("show");return} |
| $("#queue").classList.add("show");$("#queueCount").textContent=state.queue.length; |
| $("#queueList").innerHTML=state.queue.map((f,i)=>`<div class="qitem"><span>${i===0?"▶":"○"}</span><span class="qname">${escapeHtml(f.name)}</span><span class="qstatus">${human(f.size)}</span></div>`).join(""); |
| } |
| function escapeHtml(s){return s.replace(/[&<>"']/g,m=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[m]))} |
| async function selectFile(file){ |
| try{ |
| state.file=file;els.filebar.classList.remove("hidden");els.name.textContent=file.name+" · "+human(file.size); |
| els.badge.textContent="در حال تحلیل…";setProgress(1,"تحلیل فایل…"); |
| state.json=await parseFile(file);await loadLottie(state.json); |
| saveSettings(); |
| }catch(e){console.error(e);els.badge.textContent="خطا";toast(e.message||"فایل قابل خواندن نیست.");} |
| } |
| els.pick.onclick=()=>els.input.click(); |
| els.input.onchange=e=>acceptFiles([...e.target.files]); |
| ["dragenter","dragover"].forEach(ev=>els.drop.addEventListener(ev,e=>{e.preventDefault();els.drop.classList.add("over")})); |
| ["dragleave","drop"].forEach(ev=>els.drop.addEventListener(ev,e=>{e.preventDefault();els.drop.classList.remove("over")})); |
| els.drop.addEventListener("drop",e=>acceptFiles([...e.dataTransfer.files])); |
| els.play.onclick=play;els.step.onclick=()=>renderFrame(state.frame+1);els.range.oninput=e=>renderFrame(+e.target.value); |
| $$(".spd").forEach(b=>b.onclick=()=>{$$(".spd").forEach(x=>x.classList.remove("active"));b.classList.add("active");state.speed=+b.dataset.speed}); |
| els.clear.onclick=()=>{destroyAnim();state.file=state.json=null;els.filebar.classList.add("hidden");els.empty.classList.remove("hidden");els.badge.textContent="منتظر فایل";els.play.disabled=els.step.disabled=els.range.disabled=els.convert.disabled=true;clearCanvas();els.result.classList.remove("show")}; |
| $$(".fmt").forEach(b=>b.onclick=()=>{$$(".fmt").forEach(x=>x.classList.remove("active"));b.classList.add("active");state.format=b.dataset.format;els.gifSettings.style.display=state.format==="gif"?"block":"none";}); |
| $$(".bgopt").forEach(b=>b.onclick=()=>{state.bg=b.dataset.bg;updateBgUI();saveSettings()}); |
| els.bgColor.oninput=()=>{if(state.bg==="solid")updateBgUI();saveSettings()}; |
| els.convert.onclick=convert;els.cancelBtn.onclick=()=>state.cancel=true; |
| ["width","height","preserve","fps","quality","start","end","loop","single"].forEach(k=>els[k].addEventListener("change",saveSettings)); |
| els.width.addEventListener("input",()=>{if(els.preserve.checked&&state.ratio){els.height.value=Math.round(+els.width.value/state.ratio)}}); |
| $("#themeBtn").onclick=()=>{const l=document.documentElement.dataset.theme==="light";document.documentElement.dataset.theme=l?"":"light";localStorage.setItem("tgsTheme",l?"dark":"light")}; |
| loadSettings();updateBgUI();if(localStorage.getItem("tgsTheme")==="light")document.documentElement.dataset.theme="light"; |
| window.addEventListener("beforeunload",()=>{if(state.objectUrl)URL.revokeObjectURL(state.objectUrl);if(state.resultUrl)URL.revokeObjectURL(state.resultUrl);destroyAnim()}); |
| </script> |
| </body> |
| </html> |