Aplood / tgs.html
Hosdroid's picture
Upload tgs.html
3bc71ae verified
Raw
History Blame Contribute Delete
32.7 kB
<!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"></button>
<button class="secondary spd" data-speed="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";
/* TGS Converter Pro
- TGS is gzip-compressed Lottie JSON.
- Canvas renderer is used so frames can be sampled into encoders.
- GIF.js worker is loaded from the same CDN.
- WebM uses MediaRecorder where supported.
- MP4 uses native MP4 MediaRecorder when the browser exposes it; otherwise
it deliberately falls back to WebM rather than pretending a WebM is MP4.
*/
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"){
/* .lottie is a ZIP container. We support its manifest/animation JSON. */
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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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>