JFIF $ d d 7 * Z qn )ZP 0 !1 A "#a$2Qr D8 a Ri [f \m I ykI w0 c u F c Rı?lO7 к _f˓ [C $殷WF< _W ԣ s Kc ëI zyQy /_LK ℂ ;C",p F A : /] =H ~ , l s / 9 ć: [=/#f ; ) x { ٛE Q )~ = 𘙲r*2~ a _V = ' ku m F D } KYYC )( { * g& f `툪 r y ` = ^ c J. I ]( * `wq 1dđ #̩ ͑ 0 ; H ] u 搂 @:~ ו K L N s h } O I R* 8 :2 ! lD JV o ( 3 = M ( z Ȱ+i *N A r6K n S l ) ! J J Ӂ* % ݉?|D } d5: eP0 R; {$X' x F @ .ÊB {,WJ uQ ɲRI ;9 Q E琯 6 2fT .DU J ;*c P A\IL Nj! J ۱+ O\ ͔ ] ޒ S J ȧ c%A N ol ՎprU L Zԛe r E2= X D Xg VQeӓ k y P7 U* o m Q I s ,K `) 6\G3t?pgjr m ۛجw luGt f h 9uyP 0D;Uڽ"OXl i f $ ) &|ML 0 Z rm1 [ H XP l P R0 'G=i 2N +0 e 2 ]] 9 VT P O 7 h(F * 癈 ' = Q VZD F,d ߬ ~ T X G[ `le 69C R(!S2! P < 0 x < !1AQ "Raq 02Br #S CTb ? Ζ "]mH5WR 7 k.ۛ ! } Q~ + yԏ z |@ T20 S~ K e k *zF f^2X *( @ 8 r ? C I uI|֓>^Ex LgNU Y+{.R Ѫ τV YT D I 6 2 '8Y2 7'\T P.6 d &˦ @V q i |8 -O Ε ] ʔ U= T L 8= ;6c | !qfF3 a ů& ~$ l} 'NW U s$U k^S V : U#6w + +s &r+n ڐ { @ 29gL u " T Ù M=6 (^ "7 r}=6Yݾ lC u hquymp Ǧ G jhs ǜNl ɻ} o 7 #S6 a w 4 ! OS r D 5 7 % | ?x> L | / nD 6?/8 w # [ ) L 7 +6〼T A T g ! %5 Mm Z /c -{1_Je "| ^$ 'O & ޱմ < H vNqϡQ$ < 6 &Nݛ ֨tǻN (9 n ru~'όN sMG& ,uH Kw *Ī 5@ u6 BԒ i YN * f 0 _ GhKW u lT <nj- 0{ M L~ | /a v\ E = ?1 \ɟuq Ђ Y O a K ޡP q $ H8o Q˼EV ~ k 0 h' L Pp M -M" m^ !6l nմ 쮝C U T . ! :vMǨb h ډ Vc r @~p # !1QAa q ? AL0L x LU 4 d ; x " 16ad0 7.\ߋ 0 = P l v/ " c A Ay A|2pO8"d MV #1] = \ " ˡ ] k v I9 d ۓ & q ) ǐq E ꞅ v "P )q X &Q З non]я u ג./ ; gZ$ M QР 2M ]h ni 0 P $ێ 3CɏGjώy : m c # S_ l@ '_ \m|C 2`K _ 1 cIvGc $? PCQ S+D ? K aײ k Fr b 8 ύ NEدg ĝ U 3 6 #Ik+ v { v i ( ވ ? a e) X xy/*@Q Ǽ Ծ ) $ IWO l c G; !i k{ gOŀ L ('x T rb $ w) R $ & N1E td U3Uȉ1p M"N* (DNy d96.(jQ) X 5 c QɎMy W ? Q *!R > 6=7)Xj 5`J ] e 8%t ! + ' ! 1Q 5 !1 AQaq ё #2 "0BRb ? G t^ ## .l lQ T $ v , ,m 㵜5u bV =sY+ @d { N ! dn O < .- B; _w J t 6;Q Jd. Q c%p{ 1 ,sND dF H I0 Гo X ш e黅X ۢ F :)[ F G X ƹ / w _c MeD ,ʡc c . W D tA $ j@ : ) - #u c1< @ۗ9F ) KJ- hpP] _ x [ q B lb p ʖ w q " LFGd ƶ * s +ډ_Zc " ? % t[I P 6 J ]# =ɺ V v v CGs Gh1 > )6 |e y ? L ӣ m,4 G W Ui ` ] uJVo VDG< SB 6ϏQ @ TiU l y O U 0 kfV ~ ~}S Z @ *WU Ui ## ; s /[ = ! 7}" W N ] ' (L ! ~ y5 g9T̅J kbM' +s :S +B) v @ M j e C f j E 0 Y\Qn z G 1 д~ W o{T 9?` Rm yhsy3 !HAD ] m c1 ~ 2 L S u7x T ;j$ `} 4-> L# vzŏ I L S ֭ T { r jGK C ; b pU= - ` Bs K. S F w4 M q] Z dH S 0 ) tLg
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VideoForge — Encodeur Wasm</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,400;0,500;1,400&family=Unbounded:wght@400;700;900&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#08080a;
--s1:#111114;
--s2:#18181c;
--s3:#222228;
--s4:#2c2c34;
--bd:rgba(255,255,255,0.06);
--bd2:rgba(255,255,255,0.11);
--bd3:rgba(255,255,255,0.18);
--acc:#c8ff00;
--acc2:rgba(200,255,0,0.10);
--acc3:rgba(200,255,0,0.05);
--txt:#ebebea;
--muted:#7a7a88;
--dim:#44444e;
--danger:#ff4f4f;
--ok:#3dffa0;
--info:#38c4ff;
--warn:#ffb830;
--mono:'DM Mono',monospace;
--head:'Unbounded',sans-serif;
}
body{background:var(--bg);color:var(--txt);font-family:var(--mono);min-height:100vh;overflow-x:hidden}
/* Scanline texture */
body::before{
content:'';position:fixed;inset:0;pointer-events:none;z-index:0;
background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.07) 2px,rgba(0,0,0,0.07) 4px);
}
/* ── Header ── */
.hdr{
position:relative;z-index:2;
display:flex;align-items:center;gap:14px;
padding:20px 28px;
border-bottom:1px solid var(--bd);
}
.hdr-logo{
font-family:var(--head);font-size:17px;font-weight:900;
letter-spacing:-0.03em;
background:linear-gradient(135deg,var(--acc),#80ff80);
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
}
.hdr-sub{font-size:11px;color:var(--muted);margin-left:2px}
.hdr-right{margin-left:auto;display:flex;align-items:center;gap:10px}
.pill{
display:inline-flex;align-items:center;gap:6px;
padding:4px 11px;border-radius:20px;font-size:11px;
background:var(--s3);border:1px solid var(--bd2);color:var(--muted);
}
.pill.ok{background:rgba(61,255,160,0.08);border-color:rgba(61,255,160,0.2);color:var(--ok)}
.pill.loading{background:rgba(56,196,255,0.08);border-color:rgba(56,196,255,0.2);color:var(--info)}
.pill.err{background:rgba(255,79,79,0.08);border-color:rgba(255,79,79,0.2);color:var(--danger)}
.dot{width:6px;height:6px;border-radius:50%;background:currentColor}
.dot.pulse{animation:pulse 1.4s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
/* ── Layout ── */
.wrap{position:relative;z-index:1;max-width:860px;margin:0 auto;padding:28px 20px;display:grid;gap:16px}
/* ── Card ── */
.card{
background:var(--s1);border:1px solid var(--bd);border-radius:14px;
padding:22px 24px;
}
.card-hd{
font-size:10px;letter-spacing:.12em;text-transform:uppercase;
color:var(--muted);margin-bottom:18px;
display:flex;align-items:center;gap:8px;
}
.card-hd::before{content:'';width:2px;height:11px;background:var(--acc);border-radius:2px;flex-shrink:0}
/* ── Drop zone ── */
.drop{
border:1.5px dashed var(--bd2);border-radius:10px;padding:40px 20px;
text-align:center;cursor:pointer;position:relative;overflow:hidden;
transition:border-color .2s,background .2s;
background:var(--acc3);
}
.drop:hover,.drop.over{border-color:var(--acc);background:var(--acc2)}
.drop input{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%}
.drop-ico{
width:52px;height:52px;margin:0 auto 14px;
border-radius:12px;border:1px solid var(--bd2);background:var(--s2);
display:flex;align-items:center;justify-content:center;
}
.drop-ico svg{width:26px;height:26px;stroke:var(--acc);fill:none;stroke-width:1.5}
.drop-ttl{font-family:var(--head);font-size:14px;font-weight:700;margin-bottom:6px}
.drop-hint{font-size:11px;color:var(--muted)}
/* file info bar */
.fbar{display:none;align-items:center;gap:12px;margin-top:12px;
background:var(--s2);border:1px solid var(--bd);border-radius:9px;padding:12px 16px}
.fbar-ico{width:36px;height:36px;border-radius:8px;background:var(--acc2);
border:1px solid rgba(200,255,0,.18);flex-shrink:0;
display:flex;align-items:center;justify-content:center}
.fbar-ico svg{width:18px;height:18px;stroke:var(--acc);fill:none;stroke-width:1.5}
.fbar-name{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0;font-family:var(--head)}
.fbar-sz{font-size:12px;color:var(--muted);white-space:nowrap}
.fbar-sz span{color:var(--txt)}
/* ── Grid ── */
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
@media(max-width:580px){.grid2,.grid3{grid-template-columns:1fr}}
.full{grid-column:1/-1}
/* ── Field ── */
.fld{display:flex;flex-direction:column;gap:7px}
.lbl{font-size:11px;color:var(--muted);display:flex;align-items:center;justify-content:space-between}
.lbl-hint{color:var(--dim);font-size:10px}
select,input[type=number],input[type=text]{
background:var(--s2);border:1px solid var(--bd2);border-radius:8px;
color:var(--txt);font-family:var(--mono);font-size:12px;
padding:9px 12px;outline:none;width:100%;
transition:border-color .15s;-webkit-appearance:none;
}
select{cursor:pointer}
select:focus,input:focus{border-color:rgba(200,255,0,.35)}
/* ── Codec tabs ── */
.ctabs{display:flex;gap:4px;padding:4px;background:var(--s2);border-radius:10px;border:1px solid var(--bd);margin-bottom:14px}
.ctab{
flex:1;padding:8px 6px;text-align:center;border-radius:7px;cursor:pointer;
font-size:11px;color:var(--muted);transition:all .15s;border:1px solid transparent;
font-family:var(--mono);
}
.ctab:hover{color:var(--txt)}
.ctab.on{background:var(--s3);color:var(--acc);border-color:rgba(200,255,0,.14)}
.ctab-name{font-weight:500}
.ctab-sub{font-size:9px;color:inherit;opacity:.65;display:block;margin-top:2px}
/* ── Slider ── */
.srow{display:flex;align-items:center;gap:10px}
input[type=range]{
flex:1;-webkit-appearance:none;height:3px;
background:var(--s4);border-radius:2px;outline:none;cursor:pointer;
}
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none;width:15px;height:15px;border-radius:50%;
background:var(--acc);cursor:pointer;transition:transform .15s;
box-shadow:0 0 0 3px rgba(200,255,0,.18);
}
input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.25)}
.sval{font-size:13px;color:var(--acc);min-width:34px;text-align:right;font-weight:500}
/* ── Presets ── */
.presets{display:flex;flex-wrap:wrap;gap:7px}
.pset{
padding:5px 13px;border-radius:20px;border:1px solid var(--bd2);
background:var(--s2);color:var(--muted);font-family:var(--mono);
font-size:11px;cursor:pointer;transition:all .15s;
}
.pset:hover{border-color:var(--acc);color:var(--acc);background:var(--acc2)}
.pset.on{border-color:var(--acc);color:var(--acc);background:var(--acc2)}
/* ── Encode mode ── */
.modes{display:flex;gap:6px;flex-wrap:wrap}
.mode-btn{
padding:6px 14px;border-radius:20px;border:1px solid var(--bd2);
background:var(--s2);color:var(--muted);font-family:var(--mono);
font-size:11px;cursor:pointer;transition:all .15s;
}
.mode-btn:hover{border-color:var(--bd3);color:var(--txt)}
.mode-btn.on{border-color:rgba(200,255,0,.3);color:var(--acc);background:var(--acc2)}
/* ── Estimator ── */
.est-row{display:flex;align-items:center;gap:20px;flex-wrap:wrap}
.est-item{display:flex;flex-direction:column;gap:3px}
.est-lbl{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.07em}
.est-val{font-family:var(--head);font-size:20px;font-weight:700;letter-spacing:-.03em}
.est-arrow{font-size:22px;color:var(--dim)}
.est-ratio .est-val{font-size:14px;font-family:var(--mono)}
/* ── Progress ── */
.prog-wrap{margin-top:12px;display:none}
.prog-labels{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-bottom:7px}
.prog-track{height:3px;background:var(--s4);border-radius:2px;overflow:hidden}
.prog-fill{height:100%;background:linear-gradient(90deg,var(--acc),#60ff60);border-radius:2px;transition:width .4s ease;width:0%}
.prog-pct{color:var(--acc);font-weight:500}
/* ── Encode button ── */
.btn-enc{
width:100%;padding:15px;
background:var(--acc);color:#07080a;
font-family:var(--head);font-size:14px;font-weight:900;
border:none;border-radius:10px;cursor:pointer;
letter-spacing:-.02em;transition:all .15s;
display:flex;align-items:center;justify-content:center;gap:10px;
}
.btn-enc:hover:not(:disabled){background:#b8f000;transform:translateY(-1px)}
.btn-enc:active:not(:disabled){transform:none}
.btn-enc:disabled{opacity:.35;cursor:not-allowed;transform:none}
.btn-enc svg{width:17px;height:17px;stroke:currentColor;fill:none;stroke-width:2.5}
@keyframes spin{to{transform:rotate(360deg)}}
.spin{animation:spin .7s linear infinite;display:inline-block}
/* ── Log ── */
.log-box{
background:#060608;border:1px solid var(--bd);border-radius:9px;
padding:12px 14px;font-size:11px;max-height:180px;overflow-y:auto;
line-height:2;font-family:var(--mono);color:var(--dim);
}
.l-ok{color:var(--ok)}.l-err{color:var(--danger)}.l-inf{color:var(--info)}.l-warn{color:var(--warn)}
.log-line{display:block}
/* ── Result ── */
.res-card{
display:none;flex-direction:column;gap:14px;
margin-top:14px;padding:18px;
background:var(--s2);border:1px solid rgba(61,255,160,.18);border-radius:10px;
}
.res-top{display:flex;align-items:center;gap:12px}
.res-ico{
width:38px;height:38px;border-radius:9px;flex-shrink:0;
background:rgba(61,255,160,.1);border:1px solid rgba(61,255,160,.2);
display:flex;align-items:center;justify-content:center;
}
.res-ico svg{width:20px;height:20px;stroke:var(--ok);fill:none;stroke-width:2}
.res-title{font-family:var(--head);font-size:14px;font-weight:700}
.res-sub{font-size:11px;color:var(--muted);margin-top:2px}
.res-stats{display:flex;gap:24px;flex-wrap:wrap}
.rs{display:flex;flex-direction:column;gap:3px}
.rs-l{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.06em}
.rs-v{font-family:var(--head);font-size:16px;font-weight:700}
.btn-dl{
display:inline-flex;align-items:center;gap:8px;
padding:10px 18px;border-radius:8px;
background:rgba(61,255,160,.08);border:1px solid rgba(61,255,160,.22);
color:var(--ok);font-family:var(--mono);font-size:12px;font-weight:500;
cursor:pointer;text-decoration:none;transition:all .15s;align-self:flex-start;
}
.btn-dl:hover{background:rgba(61,255,160,.16)}
.btn-dl svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2}
.err-msg{
display:none;color:var(--danger);font-size:12px;
padding:10px 14px;background:rgba(255,79,79,.07);
border:1px solid rgba(255,79,79,.18);border-radius:8px;margin-top:10px;
}
/* ── Toggle ── */
.tog-row{display:flex;align-items:center;justify-content:space-between}
.tog-lbl{font-size:11px;color:var(--muted)}
.tog{position:relative;width:36px;height:19px;display:inline-block;flex-shrink:0}
.tog input{opacity:0;width:0;height:0}
.tog-sl{
position:absolute;inset:0;cursor:pointer;
background:var(--s3);border-radius:20px;border:1px solid var(--bd2);transition:.2s;
}
.tog-sl:before{
content:'';position:absolute;width:13px;height:13px;
left:2px;top:2px;background:var(--muted);border-radius:50%;transition:.2s;
}
input:checked+.tog-sl{background:rgba(200,255,0,.15);border-color:rgba(200,255,0,.3)}
input:checked+.tog-sl:before{transform:translateX(17px);background:var(--acc)}
hr.div{border:none;border-top:1px solid var(--bd);margin:4px 0}
/* ── Warning banner ── */
.warn-banner{
background:rgba(255,184,48,.07);border:1px solid rgba(255,184,48,.2);
border-radius:9px;padding:12px 16px;font-size:11px;color:var(--warn);
display:flex;gap:10px;align-items:flex-start;line-height:1.6;
}
.warn-banner svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;margin-top:1px}
</style>
</head>
<body>
<header class="hdr">
<span class="hdr-logo">VideoForge</span>
<span class="hdr-sub">/ wasm</span>
<div class="hdr-right">
<div class="pill loading" id="ffmpeg-status">
<span class="dot pulse"></span>
<span id="ffmpeg-status-txt">Chargement FFmpeg…</span>
</div>
</div>
</header>
<div class="wrap">
<!-- Warning navigateur -->
<div class="warn-banner">
<svg viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span>FFmpeg tourne <strong>dans votre navigateur</strong> via WebAssembly — aucun serveur requis. L'encodage peut être plus lent qu'un serveur dédié. Gardez l'onglet actif pendant le traitement.</span>
</div>
<!-- Upload -->
<div class="card">
<div class="card-hd">Source vidéo</div>
<div class="drop" id="drop">
<input type="file" id="file-in" accept="video/*">
<div class="drop-ico">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</div>
<div class="drop-ttl">Déposer une vidéo ici</div>
<div class="drop-hint">MP4 · MKV · AVI · MOV · WEBM · FLV — jusqu'à ~500 Mo</div>
</div>
<div class="fbar" id="fbar">
<div class="fbar-ico"><svg viewBox="0 0 24 24"><path d="M15 10l-7-4v8l7-4zm-7-7h8a4 4 0 014 4v10a4 4 0 01-4 4H8a4 4 0 01-4-4V7a4 4 0 014-4z"/></svg></div>
<span class="fbar-name" id="fname">—</span>
<span class="fbar-sz"><span id="fsize">0</span> Mo</span>
</div>
</div>
<!-- Codec -->
<div class="card">
<div class="card-hd">Codec</div>
<div class="ctabs" id="ctabs">
<div class="ctab on" data-codec="libx264" data-ext="mp4">
<span class="ctab-name">H.264</span>
<span class="ctab-sub">compatible universel</span>
</div>
<div class="ctab" data-codec="libvpx-vp9" data-ext="webm">
<span class="ctab-name">VP9</span>
<span class="ctab-sub">−40% vs H.264</span>
</div>
<div class="ctab" data-codec="mpeg4" data-ext="mp4">
<span class="ctab-name">MPEG-4</span>
<span class="ctab-sub">compatibilité max</span>
</div>
</div>
<div class="grid2">
<div class="fld">
<span class="lbl">Format de sortie</span>
<select id="fmt">
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
<option value="mkv">MKV</option>
<option value="avi">AVI</option>
</select>
</div>
<div class="fld">
<span class="lbl">Mode d'encodage</span>
<select id="enc-mode">
<option value="crf">CRF — Qualité constante</option>
<option value="vbr">VBR — Bitrate cible</option>
<option value="size">Taille cible (Mo)</option>
</select>
</div>
</div>
</div>
<!-- Compression -->
<div class="card">
<div class="card-hd">Compression</div>
<div class="fld" style="margin-bottom:16px">
<span class="lbl">Preset</span>
<div class="presets">
<button class="pset" data-p="ultrafast" data-crf="30">⚡ Rapide</button>
<button class="pset on" data-p="medium" data-crf="23">⚖ Équilibré</button>
<button class="pset" data-p="slow" data-crf="20">🎯 Qualité</button>
<button class="pset" data-p="veryslow" data-crf="17">💎 Maximum</button>
</div>
</div>
<!-- CRF -->
<div id="sec-crf">
<div class="fld">
<span class="lbl">CRF <span class="lbl-hint">0 = sans perte · 51 = basse qualité · 18-28 recommandé</span></span>
<div class="srow">
<input type="range" id="crf" min="0" max="51" value="23" step="1">
<span class="sval" id="crf-v">23</span>
</div>
</div>
</div>
<!-- VBR -->
<div id="sec-vbr" style="display:none">
<div class="grid2">
<div class="fld">
<span class="lbl">Bitrate vidéo <span class="lbl-hint">kb/s</span></span>
<input type="number" id="vbr" value="1200" min="100" max="30000" step="100">
</div>
<div class="fld">
<span class="lbl">Bitrate audio <span class="lbl-hint">kb/s</span></span>
<input type="number" id="abr" value="96" min="32" max="320" step="32">
</div>
</div>
</div>
<!-- Taille cible -->
<div id="sec-size" style="display:none">
<div class="fld">
<span class="lbl">Taille cible <span class="lbl-hint">Mo</span></span>
<input type="number" id="tgt-size" value="90" min="5" max="4000" step="5">
</div>
</div>
<hr class="div" style="margin:16px 0">
<div class="grid3">
<div class="fld">
<span class="lbl">Résolution</span>
<select id="res">
<option value="">Original</option>
<option value="1920x1080">1080p</option>
<option value="1280x720">720p</option>
<option value="854x480">480p</option>
<option value="640x360">360p</option>
</select>
</div>
<div class="fld">
<span class="lbl">FPS</span>
<select id="fps">
<option value="">Original</option>
<option value="30">30</option>
<option value="25">25</option>
<option value="24">24</option>
</select>
</div>
<div class="fld">
<span class="lbl">Audio</span>
<select id="aud">
<option value="aac">AAC (recommandé)</option>
<option value="mp3">MP3</option>
<option value="none">Supprimer</option>
</select>
</div>
</div>
<hr class="div" style="margin:16px 0">
<div class="grid2">
<div class="fld">
<div class="tog-row">
<span class="tog-lbl">Optimiser pour streaming web</span>
<label class="tog"><input type="checkbox" id="faststart" checked><span class="tog-sl"></span></label>
</div>
</div>
<div class="fld">
<div class="tog-row">
<span class="tog-lbl">Supprimer l'audio</span>
<label class="tog"><input type="checkbox" id="mute"><span class="tog-sl"></span></label>
</div>
</div>
<div class="fld">
<span class="lbl">Début <span class="lbl-hint">secondes</span></span>
<input type="number" id="trim-s" placeholder="0" min="0" step="1">
</div>
<div class="fld">
<span class="lbl">Durée max <span class="lbl-hint">secondes</span></span>
<input type="number" id="trim-d" placeholder="entier" min="1" step="1">
</div>
</div>
</div>
<!-- Estimateur -->
<div class="card" id="est-card" style="display:none">
<div class="card-hd">Estimation</div>
<div class="est-row">
<div class="est-item">
<span class="est-lbl">Source</span>
<span class="est-val" id="est-orig">—</span>
</div>
<span class="est-arrow">→</span>
<div class="est-item">
<span class="est-lbl">Estimé</span>
<span class="est-val" id="est-out" style="color:var(--acc)">—</span>
</div>
<div class="est-item est-ratio" style="margin-left:auto">
<span class="est-lbl">Réduction</span>
<span class="est-val" id="est-ratio">—</span>
</div>
</div>
<div class="prog-wrap" id="prog-wrap">
<div class="prog-labels">
<span id="prog-label">Encodage…</span>
<span class="prog-pct" id="prog-pct">0%</span>
</div>
<div class="prog-track"><div class="prog-fill" id="prog-fill"></div></div>
</div>
</div>
<!-- Bouton -->
<button class="btn-enc" id="btn-enc" disabled>
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>
Encoder la vidéo
</button>
<!-- Output -->
<div class="card" id="out-card" style="display:none">
<div class="card-hd">Journal</div>
<div class="log-box" id="log"></div>
<div class="res-card" id="res-card">
<div class="res-top">
<div class="res-ico"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></div>
<div>
<div class="res-title">Encodage terminé !</div>
<div class="res-sub" id="res-fname">—</div>
</div>
</div>
<div class="res-stats">
<div class="rs"><span class="rs-l">Source</span><span class="rs-v" id="rs-src">—</span></div>
<div class="rs"><span class="rs-l">Résultat</span><span class="rs-v" id="rs-out" style="color:var(--acc)">—</span></div>
<div class="rs"><span class="rs-l">Réduction</span><span class="rs-v" id="rs-rat" style="color:var(--ok)">—</span></div>
<div class="rs"><span class="rs-l">Durée</span><span class="rs-v" id="rs-time">—</span></div>
</div>
<a class="btn-dl" id="dl-btn" href="#" download>
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Télécharger la vidéo encodée
</a>
</div>
<div class="err-msg" id="err-msg"></div>
</div>
</div>
<!-- FFmpeg.wasm via esm.sh (cross-origin isolé via SharedArrayBuffer si COOP/COEP dispo) -->
<script type="module">
// ─── Import FFmpeg.wasm ──────────────────────────────────────────────────────
import { FFmpeg } from 'https://esm.sh/@ffmpeg/ffmpeg@0.12.15';
import { fetchFile, toBlobURL } from 'https://esm.sh/@ffmpeg/util@0.12.2';
// ─── État global ─────────────────────────────────────────────────────────────
let ffmpeg = null;
let loaded = false;
let selFile = null;
let curCodec = 'libx264';
let curPreset = 'medium';
let downloadURL = null;
const $ = id => document.getElementById(id);
const log = (msg, cls='') => {
const s = document.createElement('span');
s.className = 'log-line' + (cls ? ' '+cls : '');
s.textContent = msg;
$('log').appendChild(s);
$('log').scrollTop = 9999;
};
// ─── Chargement FFmpeg.wasm ───────────────────────────────────────────────────
async function loadFFmpeg() {
const st = $('ffmpeg-status');
const stxt = $('ffmpeg-status-txt');
try {
ffmpeg = new FFmpeg();
ffmpeg.on('log', ({ message }) => {
// Extraire le % de progression depuis la sortie FFmpeg
const m = message.match(/time=(\d+):(\d+):([\d.]+)/);
if (m && window._totalSec > 0) {
const secs = parseInt(m[1])*3600 + parseInt(m[2])*60 + parseFloat(m[3]);
const pct = Math.min(99, Math.round(secs / window._totalSec * 100));
setProgress(pct, 'Encodage en cours…');
}
});
// Charger le core WASM depuis CDN esm.sh
const baseURL = 'https://esm.sh/@ffmpeg/core@0.12.9/dist/umd';
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
});
loaded = true;
st.className = 'pill ok';
stxt.textContent = 'FFmpeg prêt';
st.querySelector('.dot').classList.remove('pulse');
if (selFile) $('btn-enc').disabled = false;
} catch(e) {
st.className = 'pill err';
stxt.textContent = 'Échec chargement';
st.querySelector('.dot').classList.remove('pulse');
console.error('FFmpeg load error:', e);
}
}
loadFFmpeg();
// ─── UI helpers ──────────────────────────────────────────────────────────────
function setProgress(pct, label='') {
$('prog-fill').style.width = pct + '%';
$('prog-pct').textContent = pct + '%';
if (label) $('prog-label').textContent = label;
}
function humanSize(bytes) {
if (bytes >= 1073741824) return (bytes/1073741824).toFixed(2) + ' Go';
if (bytes >= 1048576) return (bytes/1048576).toFixed(1) + ' Mo';
return (bytes/1024).toFixed(0) + ' Ko';
}
function updateEstimate() {
if (!selFile) return;
const orig = selFile.size / 1048576;
const mode = $('enc-mode').value;
let est;
if (mode === 'crf') {
const crf = parseInt($('crf').value);
const codec_factor = curCodec === 'libvpx-vp9' ? 0.55 : curCodec === 'mpeg4' ? 0.9 : 1;
est = orig * Math.pow(2,(crf-23)/6) * codec_factor * 0.62;
} else if (mode === 'vbr') {
const vbr = (parseInt($('vbr').value)||1200) + (parseInt($('abr').value)||96);
est = vbr * 1000 * 60 / 8 / 1048576; // estimation sur 60s
} else {
est = parseFloat($('tgt-size').value) || 90;
}
const ratio = orig > 0 ? ((1 - est/orig)*100).toFixed(0) : 0;
$('est-orig').textContent = orig.toFixed(1) + ' Mo';
$('est-out').textContent = est.toFixed(1) + ' Mo';
$('est-ratio').textContent = (ratio > 0 ? '−' : '+') + Math.abs(ratio) + '%';
$('est-ratio').style.color = ratio > 0 ? 'var(--ok)' : 'var(--danger)';
}
// ─── Fichier ─────────────────────────────────────────────────────────────────
function handleFile(f) {
if (!f || !f.type.startsWith('video/')) return;
selFile = f;
const mb = (f.size/1048576).toFixed(1);
$('fname').textContent = f.name;
$('fsize').textContent = mb;
$('fbar').style.display = 'flex';
$('est-card').style.display = 'block';
if (loaded) $('btn-enc').disabled = false;
updateEstimate();
}
$('file-in').addEventListener('change', e => handleFile(e.target.files[0]));
const dz = $('drop');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('over'); });
dz.addEventListener('dragleave', () => dz.classList.remove('over'));
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('over'); handleFile(e.dataTransfer.files[0]); });
// ─── Codec tabs ───────────────────────────────────────────────────────────────
document.querySelectorAll('.ctab').forEach(t => {
t.addEventListener('click', () => {
document.querySelectorAll('.ctab').forEach(x => x.classList.remove('on'));
t.classList.add('on');
curCodec = t.dataset.codec;
// Forcer le format compatible
const ext = t.dataset.ext;
$('fmt').value = ext;
updateEstimate();
});
});
// ─── Mode d'encodage ─────────────────────────────────────────────────────────
$('enc-mode').addEventListener('change', function() {
$('sec-crf').style.display = this.value === 'crf' ? 'block' : 'none';
$('sec-vbr').style.display = this.value === 'vbr' ? 'block' : 'none';
$('sec-size').style.display = this.value === 'size' ? 'block' : 'none';
updateEstimate();
});
// ─── CRF slider ──────────────────────────────────────────────────────────────
$('crf').addEventListener('input', function() {
$('crf-v').textContent = this.value;
updateEstimate();
});
$('vbr').addEventListener('input', updateEstimate);
$('abr').addEventListener('input', updateEstimate);
$('tgt-size').addEventListener('input', updateEstimate);
// ─── Presets ─────────────────────────────────────────────────────────────────
document.querySelectorAll('.pset').forEach(b => {
b.addEventListener('click', () => {
document.querySelectorAll('.pset').forEach(x => x.classList.remove('on'));
b.classList.add('on');
curPreset = b.dataset.p;
$('crf').value = b.dataset.crf;
$('crf-v').textContent = b.dataset.crf;
updateEstimate();
});
});
// ─── Encodage ────────────────────────────────────────────────────────────────
$('btn-enc').addEventListener('click', async () => {
if (!selFile || !loaded) return;
const btn = $('btn-enc');
btn.disabled = true;
btn.innerHTML = '<span class="spin">◌</span> Encodage en cours…';
$('out-card').style.display = 'block';
$('res-card').style.display = 'none';
$('err-msg').style.display = 'none';
$('prog-wrap').style.display = 'block';
setProgress(2, 'Lecture du fichier…');
$('log').innerHTML = '';
const fmt = $('fmt').value;
const mode = $('enc-mode').value;
const crf = $('crf').value;
const vbr = $('vbr').value;
const abr = $('abr').value;
const tgtSize = parseFloat($('tgt-size').value) || 90;
const res = $('res').value;
const fps = $('fps').value;
const aud = $('aud').value;
const mute = $('mute').checked;
const trimS = parseFloat($('trim-s').value) || 0;
const trimD = parseFloat($('trim-d').value) || 0;
// Nom de fichier de sortie
const baseName = selFile.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9_\-]/g,'_');
const outName = baseName + '_encoded.' + fmt;
const inName = 'input.' + (selFile.name.split('.').pop() || 'mp4');
const t0 = Date.now();
try {
log('→ Lecture du fichier source…', 'l-inf');
setProgress(5, 'Chargement en mémoire…');
const fileData = await fetchFile(selFile);
await ffmpeg.writeFile(inName, fileData);
log('→ Fichier chargé en mémoire WASM (' + humanSize(selFile.size) + ')', 'l-inf');
// Estimer la durée via méta si disponible
window._totalSec = 0;
try {
await new Promise(resolve => {
const vid = document.createElement('video');
vid.preload = 'metadata';
vid.src = URL.createObjectURL(selFile);
vid.onloadedmetadata = () => { window._totalSec = vid.duration || 0; resolve(); };
vid.onerror = resolve;
setTimeout(resolve, 3000);
});
} catch(_){}
// ─── Construction des arguments FFmpeg ──────────────────────────────────
const args = ['-y'];
// Trim entrée
if (trimS > 0) { args.push('-ss', String(trimS)); }
args.push('-i', inName);
if (trimD > 0) { args.push('-t', String(trimD)); }
// Codec vidéo
args.push('-c:v', curCodec);
// Qualité / bitrate
if (mode === 'crf') {
if (curCodec === 'libvpx-vp9') {
args.push('-crf', crf, '-b:v', '0');
} else {
args.push('-crf', crf);
}
} else if (mode === 'vbr') {
args.push('-b:v', vbr + 'k');
} else if (mode === 'size') {
// Calcul bitrate depuis taille cible
const dur = window._totalSec > 0 ? window._totalSec : 60;
const audioBps = (parseInt(abr)||96) * 1000;
const totalBits = tgtSize * 1048576 * 8;
const vidBitrate = Math.max(100, Math.round((totalBits - audioBps * dur) / dur / 1000));
args.push('-b:v', vidBitrate + 'k');
log(`→ Taille cible ${tgtSize} Mo → bitrate vidéo calculé : ${vidBitrate} kb/s`, 'l-inf');
}
// Preset (sauf VP9 wasm qui ne le supporte pas bien)
if (curCodec === 'libx264' || curCodec === 'mpeg4') {
args.push('-preset', curPreset);
} else if (curCodec === 'libvpx-vp9') {
const speedMap = {ultrafast:5, medium:3, slow:1, veryslow:0};
args.push('-cpu-used', String(speedMap[curPreset] ?? 3));
args.push('-deadline', 'good');
}
// Résolution
if (res) {
const [w,h] = res.split('x');
args.push('-vf', `scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2`);
}
// FPS
if (fps) args.push('-r', fps);
// Audio
if (mute || aud === 'none') {
args.push('-an');
} else {
args.push('-c:a', aud === 'mp3' ? 'libmp3lame' : 'aac');
args.push('-b:a', (parseInt(abr)||96) + 'k');
}
// Threads max
args.push('-threads', '0');
args.push(outName);
log('→ Commande : ffmpeg ' + args.join(' '), 'l-inf');
setProgress(8, 'Encodage en cours…');
await ffmpeg.exec(args);
setProgress(98, 'Lecture du résultat…');
const outData = await ffmpeg.readFile(outName);
const blob = new Blob([outData.buffer], { type: 'video/' + fmt });
const outSize = blob.size;
if (outSize === 0) throw new Error('Le fichier de sortie est vide. Essayez un autre codec ou format.');
// Nettoyage mémoire
await ffmpeg.deleteFile(inName);
await ffmpeg.deleteFile(outName);
// Lien de téléchargement
if (downloadURL) URL.revokeObjectURL(downloadURL);
downloadURL = URL.createObjectURL(blob);
const dlBtn = $('dl-btn');
dlBtn.href = downloadURL;
dlBtn.download = outName;
setProgress(100, 'Terminé !');
const elapsed = ((Date.now()-t0)/1000).toFixed(1);
const reduction = selFile.size > 0
? ((1 - outSize/selFile.size)*100).toFixed(1)
: '0';
log('✓ Encodage terminé en ' + elapsed + 's', 'l-ok');
log('→ ' + humanSize(selFile.size) + ' → ' + humanSize(outSize) + ' (−' + reduction + '%)', 'l-ok');
$('res-card').style.display = 'flex';
$('res-fname').textContent = outName;
$('rs-src').textContent = humanSize(selFile.size);
$('rs-out').textContent = humanSize(outSize);
$('rs-rat').textContent = '−' + reduction + '%';
$('rs-time').textContent = elapsed + 's';
} catch(e) {
log('✗ Erreur : ' + e.message, 'l-err');
$('err-msg').textContent = e.message;
$('err-msg').style.display = 'block';
setProgress(0, '');
}
btn.disabled = false;
btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg> Encoder à nouveau';
});
</script>
</body>
</html>