index.html 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>WeBrake · HandBrake, poured over the web</title>
  7. <link rel="preconnect" href="https://fonts.googleapis.com">
  8. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  9. <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
  10. <style>
  11. :root{
  12. --bg:#F6F0E2; /* cream */
  13. --bg-raise:#FDFAF2;
  14. --bg-sunken:#EFE7D3;
  15. --ink:#33271B;
  16. --ink-soft:#6E5E49;
  17. --line:#E2D7BE;
  18. --gold:#B98A1E; /* gold accent */
  19. --gold-bright:#D9A82E;
  20. --gold-soft:#F0E3C0;
  21. --ok:#5E7C3A; --err:#A8442F; --warn:#B0761D;
  22. --shadow:0 1px 2px rgba(51,39,27,.06),0 8px 24px rgba(51,39,27,.07);
  23. --radius:14px;
  24. }
  25. [data-theme="dark"]{
  26. --bg:#221610; /* dark roast */
  27. --bg-raise:#2C1E15;
  28. --bg-sunken:#1A100B;
  29. --ink:#F0E4D0;
  30. --ink-soft:#B39B7E;
  31. --line:#3E2C1E;
  32. --gold:#D4A937;
  33. --gold-bright:#E8C158;
  34. --gold-soft:#4A3617;
  35. --ok:#8FB061; --err:#D3745E; --warn:#D9A54B;
  36. --shadow:0 1px 2px rgba(0,0,0,.35),0 10px 28px rgba(0,0,0,.35);
  37. }
  38. *{box-sizing:border-box;margin:0;padding:0}
  39. html{color-scheme:light}
  40. [data-theme="dark"] html,[data-theme="dark"]{color-scheme:dark}
  41. body{
  42. font-family:'Inter',system-ui,sans-serif;font-size:14.5px;line-height:1.5;
  43. background:var(--bg);color:var(--ink);
  44. transition:background .3s,color .3s;min-height:100vh;
  45. }
  46. h1,h2,h3{font-family:'Fraunces',serif;font-weight:700;letter-spacing:-.01em}
  47. code,.mono,textarea.mono,input.mono{font-family:'JetBrains Mono',monospace;font-size:12.5px}
  48. a{color:var(--gold)}
  49. button{font:inherit;cursor:pointer;border:none;background:none;color:inherit}
  50. .wrap{max-width:1100px;margin:0 auto;padding:0 20px 80px}
  51. /* ---------- header ---------- */
  52. header{display:flex;align-items:center;gap:14px;padding:26px 0 20px;flex-wrap:wrap}
  53. .logo{display:flex;align-items:center;gap:12px}
  54. .logo svg{display:block}
  55. .logo h1{font-size:26px}
  56. .logo .tag{font-size:12px;color:var(--ink-soft);margin-top:-2px}
  57. .chips{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto;align-items:center}
  58. .chip{font-size:11.5px;font-weight:600;padding:4px 10px;border-radius:999px;
  59. border:1px solid var(--line);background:var(--bg-raise);color:var(--ink-soft)}
  60. .chip.hw{border-color:var(--gold);color:var(--gold);background:transparent}
  61. .theme-btn{width:38px;height:38px;border-radius:50%;border:1px solid var(--line);
  62. background:var(--bg-raise);display:grid;place-items:center;transition:transform .2s}
  63. .theme-btn:hover{transform:rotate(20deg)}
  64. /* ---------- cards ---------- */
  65. .card{background:var(--bg-raise);border:1px solid var(--line);border-radius:var(--radius);
  66. box-shadow:var(--shadow);padding:22px;margin-bottom:22px}
  67. .card h2{font-size:19px;margin-bottom:4px}
  68. .card .sub{color:var(--ink-soft);font-size:13px;margin-bottom:16px}
  69. /* ---------- dropzone ---------- */
  70. .drop{border:2px dashed var(--line);border-radius:var(--radius);padding:34px;
  71. text-align:center;color:var(--ink-soft);transition:.2s;cursor:pointer;background:var(--bg-sunken)}
  72. .drop.hover{border-color:var(--gold);color:var(--gold);background:var(--gold-soft)}
  73. .drop strong{color:var(--ink)}
  74. .upbar{margin-top:14px;display:none}
  75. .upbar.show{display:block}
  76. /* ---------- progress (signature: gold pour) ---------- */
  77. .bar{height:10px;border-radius:999px;background:var(--bg-sunken);overflow:hidden;
  78. border:1px solid var(--line);position:relative}
  79. .bar>i{display:block;height:100%;width:0;border-radius:999px;
  80. background:linear-gradient(90deg,var(--gold) 0%,var(--gold-bright) 50%,var(--gold) 100%);
  81. background-size:200% 100%;animation:pour 1.6s linear infinite;transition:width .4s ease}
  82. .bar.static>i{animation:none}
  83. @keyframes pour{from{background-position:0 0}to{background-position:-200% 0}}
  84. @media (prefers-reduced-motion:reduce){.bar>i{animation:none}}
  85. /* ---------- sources ---------- */
  86. .src-list{display:flex;flex-direction:column;gap:8px;margin-top:14px}
  87. .src{display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--line);
  88. border-radius:10px;background:var(--bg-sunken);cursor:pointer;transition:.15s}
  89. .src:hover{border-color:var(--gold)}
  90. .src.sel{border-color:var(--gold);background:var(--gold-soft);box-shadow:inset 0 0 0 1px var(--gold)}
  91. .src .name{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  92. .src .meta{margin-left:auto;color:var(--ink-soft);font-size:12px;white-space:nowrap}
  93. .src .del{color:var(--ink-soft);padding:2px 6px;border-radius:6px}
  94. .src .del:hover{color:var(--err)}
  95. /* ---------- tabs & fields ---------- */
  96. .tabs{display:flex;gap:2px;flex-wrap:wrap;border-bottom:1px solid var(--line);margin:6px 0 18px}
  97. .tab{padding:9px 14px;font-weight:600;font-size:13px;color:var(--ink-soft);
  98. border-radius:8px 8px 0 0;border-bottom:2px solid transparent;margin-bottom:-1px}
  99. .tab.on{color:var(--gold);border-bottom-color:var(--gold)}
  100. .pane{display:none}.pane.on{display:block}
  101. .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:14px 16px}
  102. .field label{display:block;font-size:12px;font-weight:600;color:var(--ink-soft);
  103. margin-bottom:5px;text-transform:uppercase;letter-spacing:.04em}
  104. .field input[type=text],.field input[type=number],.field select,.field textarea{
  105. width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:8px;
  106. background:var(--bg-sunken);color:var(--ink);font:inherit;font-size:13.5px}
  107. .field input:focus,.field select:focus,.field textarea:focus{
  108. outline:2px solid var(--gold);outline-offset:1px;border-color:var(--gold)}
  109. .field.wide{grid-column:1/-1}
  110. .check{display:flex;align-items:center;gap:8px;font-size:13.5px;padding:6px 0}
  111. .check input{accent-color:var(--gold);width:16px;height:16px}
  112. .hint{font-size:11.5px;color:var(--ink-soft);margin-top:4px}
  113. textarea{resize:vertical;min-height:70px}
  114. /* ---------- buttons ---------- */
  115. .btn{display:inline-flex;align-items:center;gap:8px;padding:10px 18px;border-radius:10px;
  116. font-weight:600;font-size:14px;border:1px solid var(--line);background:var(--bg-sunken);
  117. color:var(--ink);transition:.15s}
  118. .btn:hover{border-color:var(--gold)}
  119. .btn.gold{background:var(--gold);border-color:var(--gold);color:#fff}
  120. [data-theme="dark"] .btn.gold{color:#221610}
  121. .btn.gold:hover{background:var(--gold-bright);border-color:var(--gold-bright)}
  122. .btn.sm{padding:6px 12px;font-size:12.5px;border-radius:8px}
  123. .btn.danger:hover{border-color:var(--err);color:var(--err)}
  124. .btn:disabled{opacity:.45;cursor:not-allowed}
  125. .actions{display:flex;gap:10px;align-items:center;margin-top:18px;flex-wrap:wrap}
  126. /* ---------- jobs ---------- */
  127. .job{border:1px solid var(--line);border-radius:12px;padding:16px;margin-top:12px;background:var(--bg-sunken)}
  128. .job .row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
  129. .job .fname{font-weight:600}
  130. .status{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
  131. padding:3px 9px;border-radius:999px;border:1px solid var(--line);color:var(--ink-soft)}
  132. .status.running,.status.scanning{color:var(--gold);border-color:var(--gold)}
  133. .status.done{color:var(--ok);border-color:var(--ok)}
  134. .status.failed{color:var(--err);border-color:var(--err)}
  135. .status.cancelled{color:var(--warn);border-color:var(--warn)}
  136. .job .stats{margin-left:auto;color:var(--ink-soft);font-size:12.5px;display:flex;gap:14px}
  137. .job .bar{margin-top:10px}
  138. .job .btns{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
  139. .job .errline{color:var(--err);font-size:12.5px;margin-top:8px}
  140. .logbox{display:none;margin-top:10px;background:var(--bg);border:1px solid var(--line);
  141. border-radius:8px;padding:10px;max-height:260px;overflow:auto;white-space:pre-wrap;font-size:11.5px}
  142. .logbox.show{display:block}
  143. .empty{color:var(--ink-soft);text-align:center;padding:26px;font-size:13.5px}
  144. .toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%) translateY(20px);
  145. background:var(--ink);color:var(--bg);padding:10px 20px;border-radius:10px;font-size:13.5px;
  146. opacity:0;pointer-events:none;transition:.25s;z-index:50;box-shadow:var(--shadow)}
  147. .toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
  148. footer{color:var(--ink-soft);font-size:12px;text-align:center;padding-top:8px}
  149. .token-panel{border-color:var(--gold)}
  150. .theme-btn.attn{border-color:var(--gold);color:var(--gold);
  151. box-shadow:0 0 0 3px var(--gold-soft);animation:knock 1.4s ease-in-out infinite}
  152. @keyframes knock{0%,100%{transform:none}50%{transform:translateY(-2px)}}
  153. @media (prefers-reduced-motion:reduce){.theme-btn.attn{animation:none}}
  154. .srcerr{color:var(--err);text-align:center;padding:18px;font-size:13px}
  155. .storage{margin-top:14px}
  156. .bar.thin{height:6px}
  157. .storage .hint{margin-top:5px}
  158. .storage.low .bar>i{background:var(--err);animation:none}
  159. .storage.low .hint{color:var(--err)}
  160. .active-src{font-size:13px;color:var(--ink-soft)}
  161. .active-src b{color:var(--gold)}
  162. @media(max-width:640px){.job .stats{margin-left:0;width:100%}}
  163. </style>
  164. </head>
  165. <body>
  166. <div class="wrap">
  167. <header>
  168. <div class="logo">
  169. <!-- coffee-ring / reel mark -->
  170. <svg width="42" height="42" viewBox="0 0 42 42" fill="none" aria-hidden="true">
  171. <circle cx="21" cy="21" r="17" stroke="var(--gold)" stroke-width="3.5"/>
  172. <circle cx="21" cy="21" r="9" stroke="var(--gold)" stroke-width="2" stroke-dasharray="4 5"/>
  173. <circle cx="21" cy="21" r="3" fill="var(--gold)"/>
  174. </svg>
  175. <div>
  176. <h1>WeBrake</h1>
  177. <div class="tag">HandBrake, poured over the web</div>
  178. </div>
  179. </div>
  180. <div class="chips" id="chips">
  181. <span class="chip" id="chip-hb">HandBrake …</span>
  182. <button class="theme-btn" id="tokenBtn" title="API token" aria-label="API token settings">
  183. <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor"
  184. stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  185. <circle cx="7.5" cy="15.5" r="4.5"/><path d="m10.7 12.3 9.3-9.3M16 5l3 3M13 8l3 3"/>
  186. </svg>
  187. </button>
  188. <button class="theme-btn" id="themeBtn" title="Toggle dark roast" aria-label="Toggle theme">
  189. <svg id="themeIcon" width="18" height="18" viewBox="0 0 24 24" fill="none"
  190. stroke="currentColor" stroke-width="2" stroke-linecap="round"></svg>
  191. </button>
  192. </div>
  193. </header>
  194. <!-- ============ token panel ============ -->
  195. <section class="card token-panel" id="tokenPanel" hidden>
  196. <h2>API token</h2>
  197. <div class="sub">Paste the <code>api_token</code> from <code>/opt/webrake/token.json</code>, or upload the file itself — it's read locally in your browser and never sent anywhere except as a request header.</div>
  198. <div class="grid">
  199. <div class="field wide"><label for="tokenInput">Token</label>
  200. <input type="password" id="tokenInput" class="mono" placeholder="paste api_token or a bot token" autocomplete="off">
  201. </div>
  202. </div>
  203. <div class="actions">
  204. <button class="btn gold" id="tokenSave">Save token</button>
  205. <button class="btn" id="tokenFileBtn">Upload token.json…</button>
  206. <input type="file" id="tokenFile" accept=".json,application/json" hidden>
  207. <button class="btn danger" id="tokenClear">Clear</button>
  208. <span class="hint" id="tokenStatus"></span>
  209. </div>
  210. </section>
  211. <!-- ============ upload ============ -->
  212. <section class="card">
  213. <h2>1 · Pour in your media</h2>
  214. <div class="sub">Drop a video file to upload it to the container for remuxing or re-encoding.</div>
  215. <div class="drop" id="drop" tabindex="0" role="button" aria-label="Upload a video file">
  216. <strong>Drop a file here</strong> or click to browse
  217. <div class="hint">MKV, MP4, AVI, TS, MOV, WebM — anything HandBrake can scan.</div>
  218. </div>
  219. <input type="file" id="fileInput" hidden>
  220. <div class="upbar" id="upbar">
  221. <div class="bar"><i id="upfill"></i></div>
  222. <div class="hint" id="uptext">Uploading…</div>
  223. </div>
  224. <div class="storage" id="storage" hidden>
  225. <div class="bar static thin"><i id="storefill"></i></div>
  226. <div class="hint" id="storetext"></div>
  227. </div>
  228. <div class="src-list" id="srcList"></div>
  229. </section>
  230. <!-- ============ options ============ -->
  231. <section class="card">
  232. <h2>2 · Dial in the brew</h2>
  233. <div class="sub">Every HandBrake feature is here — pick a preset, fine-tune the groups, or drop raw flags in Advanced.</div>
  234. <div class="grid" style="margin-bottom:4px">
  235. <div class="field">
  236. <label for="preset">Preset</label>
  237. <select id="preset"><option value="">(none — manual settings)</option></select>
  238. </div>
  239. <div class="field">
  240. <label for="o_format">Container</label>
  241. <select id="o_format">
  242. <option value="av_mkv">MKV (av_mkv)</option>
  243. <option value="av_mp4">MP4 (av_mp4)</option>
  244. <option value="av_webm">WebM (av_webm)</option>
  245. </select>
  246. </div>
  247. <div class="field">
  248. <label for="o_output_name">Output name (no extension)</label>
  249. <input type="text" id="o_output_name" placeholder="defaults to source name">
  250. </div>
  251. </div>
  252. <div class="tabs" id="tabs">
  253. <button class="tab on" data-pane="p-src">Source</button>
  254. <button class="tab" data-pane="p-vid">Video</button>
  255. <button class="tab" data-pane="p-aud">Audio</button>
  256. <button class="tab" data-pane="p-sub">Subtitles</button>
  257. <button class="tab" data-pane="p-pic">Picture</button>
  258. <button class="tab" data-pane="p-fil">Filters</button>
  259. <button class="tab" data-pane="p-adv">Advanced</button>
  260. </div>
  261. <!-- Source -->
  262. <div class="pane on" id="p-src"><div class="grid">
  263. <div class="field"><label>Title #</label><input type="number" id="o_title" min="0" placeholder="auto"></div>
  264. <div class="field"><label>Chapters</label><input type="text" id="o_chapters" placeholder="e.g. 1-3"></div>
  265. <div class="field"><label>Angle</label><input type="number" id="o_angle" min="1" placeholder=""></div>
  266. <div class="field"><label>Min duration (s)</label><input type="number" id="o_min_duration" placeholder=""></div>
  267. <div class="field"><label>Start at</label><input type="text" id="o_start_at" placeholder="seconds:30 / frames:100 / duration:00:01:00"></div>
  268. <div class="field"><label>Stop at</label><input type="text" id="o_stop_at" placeholder="same units as start"></div>
  269. <div class="field"><label>Previews</label><input type="text" id="o_previews" placeholder="e.g. 30:1"></div>
  270. <div class="field"><label>Start at preview #</label><input type="number" id="o_start_at_preview" placeholder=""></div>
  271. <div class="field wide">
  272. <label class="check"><input type="checkbox" id="o_main_feature"> Main feature title only (--main-feature)</label>
  273. <label class="check"><input type="checkbox" id="o_markers" checked> Chapter markers (--markers)</label>
  274. <label class="check"><input type="checkbox" id="o_optimize"> Web optimized / fast start (--optimize)</label>
  275. <label class="check"><input type="checkbox" id="o_align_av"> Align A/V start (--align-av)</label>
  276. <label class="check"><input type="checkbox" id="o_inline_parameter_sets"> Inline parameter sets (--inline-parameter-sets)</label>
  277. </div>
  278. <div class="field wide" id="scanBox">
  279. <button class="btn sm" id="scanBtn" type="button">Scan source for titles &amp; tracks</button>
  280. <div class="hint mono" id="scanOut"></div>
  281. </div>
  282. </div></div>
  283. <!-- Video -->
  284. <div class="pane" id="p-vid"><div class="grid">
  285. <div class="field"><label>Encoder</label><select id="o_encoder"><option value="">(preset default)</option></select>
  286. <div class="hint" id="hwHint"></div></div>
  287. <div class="field"><label>Encoder preset</label><input type="text" id="o_encoder_preset" placeholder="e.g. medium / p5 / quality"></div>
  288. <div class="field"><label>Encoder tune</label><input type="text" id="o_encoder_tune" placeholder="e.g. film, animation"></div>
  289. <div class="field"><label>Profile</label><input type="text" id="o_encoder_profile" placeholder="e.g. main, high, main10"></div>
  290. <div class="field"><label>Level</label><input type="text" id="o_encoder_level" placeholder="e.g. 4.1"></div>
  291. <div class="field"><label>Quality (CQ/RF)</label><input type="number" id="o_quality" step="0.25" placeholder="e.g. 22"></div>
  292. <div class="field"><label>Bitrate kb/s (--vb)</label><input type="number" id="o_vb" placeholder="overrides quality"></div>
  293. <div class="field"><label>Framerate (--rate)</label><input type="text" id="o_rate" placeholder="e.g. 23.976, 30"></div>
  294. <div class="field"><label>Framerate mode</label>
  295. <select id="o_fr_mode"><option value="">source default</option>
  296. <option value="cfr">Constant (--cfr)</option>
  297. <option value="vfr">Variable (--vfr)</option>
  298. <option value="pfr">Peak (--pfr)</option></select></div>
  299. <div class="field wide"><label>Extra encoder options (--encopts)</label>
  300. <input type="text" class="mono" id="o_encopts" placeholder="key=value:key=value"></div>
  301. <div class="field wide">
  302. <label class="check"><input type="checkbox" id="o_two_pass"> Two-pass encode (--two-pass)</label>
  303. <label class="check"><input type="checkbox" id="o_turbo"> Turbo first pass (--turbo)</label>
  304. <label class="check"><input type="checkbox" id="o_hwdec"> Hardware decoding (--enable-hw-decoding)</label>
  305. </div>
  306. </div></div>
  307. <!-- Audio -->
  308. <div class="pane" id="p-aud"><div class="grid">
  309. <div class="field"><label>Tracks (--audio)</label><input type="text" id="o_audio" placeholder="e.g. 1,2 or leave blank"></div>
  310. <div class="field"><label>Language list</label><input type="text" id="o_audio_lang_list" placeholder="e.g. eng,jpn"></div>
  311. <div class="field"><label>Encoder(s) (--aencoder)</label><input type="text" id="o_aencoder" placeholder="e.g. copy, av_aac, opus, flac"></div>
  312. <div class="field"><label>Copy mask</label><input type="text" id="o_audio_copy_mask" placeholder="e.g. aac,ac3,dtshd,truehd"></div>
  313. <div class="field"><label>Fallback encoder</label><input type="text" id="o_audio_fallback" placeholder="e.g. av_aac"></div>
  314. <div class="field"><label>Bitrate(s) kb/s (--ab)</label><input type="text" id="o_ab" placeholder="e.g. 160,256"></div>
  315. <div class="field"><label>Quality(ies) (--aq)</label><input type="text" id="o_aq" placeholder=""></div>
  316. <div class="field"><label>Compression (--ac)</label><input type="text" id="o_ac" placeholder=""></div>
  317. <div class="field"><label>Mixdown</label><input type="text" id="o_mixdown" placeholder="e.g. stereo, 5point1"></div>
  318. <div class="field"><label>Normalize mix</label><input type="text" id="o_normalize_mix" placeholder="0 or 1"></div>
  319. <div class="field"><label>Samplerate kHz (--arate)</label><input type="text" id="o_arate" placeholder="e.g. 48"></div>
  320. <div class="field"><label>DRC (--drc)</label><input type="text" id="o_drc" placeholder="1.0–4.0"></div>
  321. <div class="field"><label>Gain dB</label><input type="text" id="o_gain" placeholder="e.g. 2"></div>
  322. <div class="field"><label>Dither (--adither)</label><input type="text" id="o_adither" placeholder="auto / none / …"></div>
  323. <div class="field wide"><label>Track name(s) (--aname)</label><input type="text" id="o_aname" placeholder="comma-separated"></div>
  324. <div class="field wide">
  325. <label class="check"><input type="checkbox" id="o_all_audio"> Include all audio tracks (--all-audio)</label>
  326. <label class="check"><input type="checkbox" id="o_first_audio"> First audio track only (--first-audio)</label>
  327. </div>
  328. </div></div>
  329. <!-- Subtitles -->
  330. <div class="pane" id="p-sub"><div class="grid">
  331. <div class="field"><label>Tracks (--subtitle)</label><input type="text" id="o_subtitle" placeholder="e.g. 1,2 / scan"></div>
  332. <div class="field"><label>Language list</label><input type="text" id="o_subtitle_lang_list" placeholder="e.g. eng"></div>
  333. <div class="field"><label>Forced only (--subtitle-forced)</label><input type="text" id="o_subtitle_forced" placeholder="track #s or blank"></div>
  334. <div class="field"><label>Burn in (--subtitle-burned)</label><input type="text" id="o_subtitle_burned" placeholder="track # / native / none"></div>
  335. <div class="field"><label>Default track</label><input type="text" id="o_subtitle_default" placeholder="track # / none"></div>
  336. <div class="field"><label>Track name(s) (--subname)</label><input type="text" id="o_subname" placeholder=""></div>
  337. <div class="field"><label>Native language</label><input type="text" id="o_native_language" placeholder="e.g. eng"></div>
  338. <div class="field wide"><label>External SRT file path (--srt-file)</label>
  339. <input type="text" class="mono" id="o_srt_file" placeholder="/var/lib/webrake/uploads/…"></div>
  340. <div class="field"><label>SRT codeset</label><input type="text" id="o_srt_codeset" placeholder="UTF-8"></div>
  341. <div class="field"><label>SRT offset ms</label><input type="text" id="o_srt_offset" placeholder=""></div>
  342. <div class="field"><label>SRT language</label><input type="text" id="o_srt_lang" placeholder="e.g. eng"></div>
  343. <div class="field"><label>SRT burn (track #)</label><input type="text" id="o_srt_burn" placeholder=""></div>
  344. <div class="field wide">
  345. <label class="check"><input type="checkbox" id="o_all_subtitles"> Include all subtitles (--all-subtitles)</label>
  346. <label class="check"><input type="checkbox" id="o_first_subtitle"> First subtitle only (--first-subtitle)</label>
  347. <label class="check"><input type="checkbox" id="o_native_dub"> Prefer native-language dub (--native-dub)</label>
  348. </div>
  349. </div></div>
  350. <!-- Picture -->
  351. <div class="pane" id="p-pic"><div class="grid">
  352. <div class="field"><label>Width</label><input type="number" id="o_width" placeholder=""></div>
  353. <div class="field"><label>Height</label><input type="number" id="o_height" placeholder=""></div>
  354. <div class="field"><label>Max width</label><input type="number" id="o_maxWidth" placeholder=""></div>
  355. <div class="field"><label>Max height</label><input type="number" id="o_maxHeight" placeholder=""></div>
  356. <div class="field"><label>Crop T:B:L:R</label><input type="text" id="o_crop" placeholder="e.g. 0:0:0:0"></div>
  357. <div class="field"><label>Crop mode</label>
  358. <select id="o_crop_mode"><option value="">default</option>
  359. <option>auto</option><option>conservative</option><option>none</option></select></div>
  360. <div class="field"><label>Anamorphic</label>
  361. <select id="o_anamorphic"><option value="">default</option>
  362. <option value="auto_anamorphic">auto</option>
  363. <option value="loose_anamorphic">loose</option>
  364. <option value="custom_anamorphic">custom</option>
  365. <option value="non_anamorphic">none</option></select></div>
  366. <div class="field"><label>Display width</label><input type="number" id="o_display_width" placeholder=""></div>
  367. <div class="field"><label>Pixel aspect X:Y</label><input type="text" id="o_pixel_aspect" placeholder="e.g. 1:1"></div>
  368. <div class="field"><label>Modulus</label><input type="number" id="o_modulus" placeholder="2"></div>
  369. <div class="field"><label>Color matrix</label><input type="text" id="o_color_matrix" placeholder="709 / 601 / 2020 / …"></div>
  370. <div class="field wide">
  371. <label class="check"><input type="checkbox" id="o_keep_display_aspect"> Keep display aspect (--keep-display-aspect)</label>
  372. </div>
  373. </div></div>
  374. <!-- Filters -->
  375. <div class="pane" id="p-fil"><div class="grid">
  376. <div class="field"><label>Comb detect</label><input type="text" id="o_comb_detect" placeholder="blank=off · 'default' or settings"></div>
  377. <div class="field"><label>Deinterlace</label><input type="text" id="o_deinterlace" placeholder="'default' or preset/settings"></div>
  378. <div class="field"><label>Decomb</label><input type="text" id="o_decomb" placeholder="'default' or settings"></div>
  379. <div class="field"><label>Detelecine</label><input type="text" id="o_detelecine" placeholder="'default' or settings"></div>
  380. <div class="field"><label>Denoise hqdn3d</label><input type="text" id="o_hqdn3d" placeholder="light / medium / strong / custom"></div>
  381. <div class="field"><label>Denoise NLMeans</label><input type="text" id="o_nlmeans" placeholder="ultralight … strong"></div>
  382. <div class="field"><label>NLMeans tune</label><input type="text" id="o_nlmeans_tune" placeholder="film / grain / …"></div>
  383. <div class="field"><label>Chroma smooth</label><input type="text" id="o_chroma_smooth" placeholder=""></div>
  384. <div class="field"><label>Unsharp</label><input type="text" id="o_unsharp" placeholder="light / medium / strong"></div>
  385. <div class="field"><label>Lapsharp</label><input type="text" id="o_lapsharp" placeholder=""></div>
  386. <div class="field"><label>Deblock</label><input type="text" id="o_deblock" placeholder=""></div>
  387. <div class="field"><label>Rotate</label><input type="text" id="o_rotate" placeholder="angle=90:hflip=0"></div>
  388. <div class="field"><label>Pad</label><input type="text" id="o_pad" placeholder="W:H:color:X:Y"></div>
  389. <div class="field"><label>Colorspace</label><input type="text" id="o_colorspace" placeholder="bt709 / bt2020 / …"></div>
  390. <div class="field wide">
  391. <label class="check"><input type="checkbox" id="o_grayscale"> Grayscale (--grayscale)</label>
  392. </div>
  393. <div class="hint wide field">Filter fields map to HandBrake's optional-value flags: enter <code>default</code> to enable with defaults, or paste full settings strings.</div>
  394. </div></div>
  395. <!-- Advanced -->
  396. <div class="pane" id="p-adv"><div class="grid">
  397. <div class="field wide"><label>Raw HandBrakeCLI flags — everything else lives here</label>
  398. <textarea id="o_extra_args" class="mono" placeholder="--preset-import-file /path/custom.json --subtitle-lang-list eng --queue-import-file …"></textarea>
  399. <div class="hint">Appended verbatim to the command line, so the entire HandBrake flag surface is available. See <code>HandBrakeCLI --help</code>.</div>
  400. </div>
  401. <div class="field wide"><label>Command preview</label>
  402. <textarea id="cmdPreview" class="mono" readonly rows="3"></textarea></div>
  403. </div></div>
  404. <div class="actions">
  405. <button class="btn gold" id="startBtn">Start encode</button>
  406. <span class="hint" id="startHint">Select a source above first.</span>
  407. </div>
  408. </section>
  409. <!-- ============ queue ============ -->
  410. <section class="card">
  411. <h2>3 · The counter</h2>
  412. <div class="sub">Live queue — watch progress, then download the finished cup back to this PC.</div>
  413. <div id="jobs"><div class="empty">Nothing brewing yet.</div></div>
  414. </section>
  415. <footer>WeBrake · <span id="verline"></span> · <a href="https://gogs.av2x.dev/av2x/WeBrake">source</a></footer>
  416. </div>
  417. <div class="toast" id="toast"></div>
  418. <script>
  419. "use strict";
  420. /* ---------------- helpers ---------------- */
  421. const $ = s => document.querySelector(s);
  422. const $$ = s => [...document.querySelectorAll(s)];
  423. let API_TOKEN = localStorage.getItem("webrake_token") || "";
  424. let AUTH_NEEDED = false;
  425. function headers(extra){ const h = extra||{}; if(API_TOKEN) h["X-API-Token"]=API_TOKEN; return h; }
  426. function markAuthNeeded(){
  427. AUTH_NEEDED = true;
  428. $("#tokenBtn").classList.add("attn");
  429. $("#tokenStatus").textContent = "A token is required — paste it or upload token.json.";
  430. if($("#tokenPanel").hidden){ $("#tokenPanel").hidden = false; }
  431. }
  432. async function api(path, opts={}){
  433. opts.headers = headers(opts.headers);
  434. const r = await fetch(path, opts);
  435. if(r.status===401){ markAuthNeeded(); throw new Error("API token required"); }
  436. if(!r.ok){ let e={}; try{e=await r.json()}catch{} throw new Error(e.error||r.statusText); }
  437. return r;
  438. }
  439. function toast(msg){ const t=$("#toast"); t.textContent=msg; t.classList.add("show");
  440. clearTimeout(t._h); t._h=setTimeout(()=>t.classList.remove("show"),2600); }
  441. function fmtSize(b){ if(b>1e9)return (b/1e9).toFixed(2)+" GB"; if(b>1e6)return (b/1e6).toFixed(1)+" MB";
  442. return (b/1e3).toFixed(0)+" KB"; }
  443. function fmtEta(s){ if(!s)return ""; const h=~~(s/3600),m=~~(s%3600/60),ss=~~(s%60);
  444. return (h?h+"h ":"")+(m?m+"m ":"")+ss+"s"; }
  445. function esc(x){ const d=document.createElement("div"); d.textContent=x??""; return d.innerHTML; }
  446. /* ---------------- theme ---------------- */
  447. const sunPath = '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>';
  448. const moonPath = '<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>';
  449. function setTheme(t){
  450. document.documentElement.dataset.theme=t;
  451. localStorage.setItem("webrake_theme",t);
  452. $("#themeIcon").innerHTML = t==="dark" ? sunPath : moonPath;
  453. }
  454. setTheme(localStorage.getItem("webrake_theme") ||
  455. (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"));
  456. $("#themeBtn").onclick = () =>
  457. setTheme(document.documentElement.dataset.theme==="dark" ? "light" : "dark");
  458. /* ---------------- token panel ---------------- */
  459. function tokenStatusLine(){
  460. $("#tokenStatus").textContent = API_TOKEN
  461. ? "Token saved in this browser." : (AUTH_NEEDED
  462. ? "A token is required — paste it or upload token.json." : "No token set (not required unless auth is enabled).");
  463. $("#tokenInput").value = API_TOKEN;
  464. }
  465. $("#tokenBtn").onclick = () => {
  466. const p = $("#tokenPanel"); p.hidden = !p.hidden;
  467. if(!p.hidden){ tokenStatusLine(); $("#tokenInput").focus(); }
  468. };
  469. function saveToken(tok){
  470. API_TOKEN = (tok||"").trim();
  471. localStorage.setItem("webrake_token", API_TOKEN);
  472. AUTH_NEEDED = false;
  473. $("#tokenBtn").classList.remove("attn");
  474. tokenStatusLine();
  475. toast(API_TOKEN ? "Token saved" : "Token cleared");
  476. bootData(); // re-pull everything with the new credentials
  477. }
  478. $("#tokenSave").onclick = () => saveToken($("#tokenInput").value);
  479. $("#tokenInput").addEventListener("keydown", e => { if(e.key==="Enter") saveToken(e.target.value); });
  480. $("#tokenClear").onclick = () => saveToken("");
  481. $("#tokenFileBtn").onclick = () => $("#tokenFile").click();
  482. $("#tokenFile").onchange = () => {
  483. const f = $("#tokenFile").files[0]; if(!f) return;
  484. const rd = new FileReader();
  485. rd.onload = () => {
  486. try{
  487. const j = JSON.parse(rd.result);
  488. const tok = j.api_token || Object.values(j.bots||{})[0];
  489. if(!tok) throw new Error("no api_token or bot token found");
  490. saveToken(tok);
  491. $("#tokenPanel").hidden = true;
  492. }catch(e){ $("#tokenStatus").textContent = "Couldn't read that file: "+e.message; }
  493. };
  494. rd.readAsText(f);
  495. $("#tokenFile").value = "";
  496. };
  497. /* ---------------- tabs ---------------- */
  498. $$("#tabs .tab").forEach(t => t.onclick = () => {
  499. $$("#tabs .tab").forEach(x=>x.classList.remove("on"));
  500. $$(".pane").forEach(x=>x.classList.remove("on"));
  501. t.classList.add("on"); $("#"+t.dataset.pane).classList.add("on");
  502. });
  503. /* ---------------- capabilities / presets ---------------- */
  504. let CAPS = {}, metaLoaded = false;
  505. async function loadMeta(){
  506. try{
  507. const v = await (await fetch("/api/version")).json();
  508. $("#verline").textContent = "v"+v.webrake+" · HandBrake "+(v.handbrake||"not found");
  509. if(v.auth_required && !API_TOKEN){ markAuthNeeded(); return; }
  510. if(metaLoaded) return;
  511. CAPS = await (await api("/api/capabilities")).json();
  512. metaLoaded = true;
  513. $("#chip-hb").textContent = CAPS.handbrake_found
  514. ? "HandBrake "+(CAPS.handbrake_version||"") : "HandBrake missing!";
  515. const chips = $("#chips"), btn = $("#themeBtn");
  516. for(const [k,label] of [["qsv","Intel QSV"],["nvenc","NVENC"],["vce","AMD VCE"],["vaapi","VAAPI"]]){
  517. if(CAPS[k]){ const c=document.createElement("span"); c.className="chip hw";
  518. c.textContent="⚡ "+label; chips.insertBefore(c,btn); }
  519. }
  520. const sel = $("#o_encoder");
  521. (CAPS.encoders||[]).forEach(e=>{
  522. const o=document.createElement("option"); o.value=e;
  523. o.textContent=(CAPS.hw_encoders||[]).includes(e) ? "⚡ "+e+" (GPU)" : e;
  524. sel.appendChild(o);
  525. });
  526. const hw=(CAPS.hw_encoders||[]);
  527. $("#hwHint").textContent = hw.length
  528. ? "GPU passthru detected — ⚡ encoders run on hardware."
  529. : "No hardware encoders detected; software encoders only.";
  530. const p = await (await api("/api/presets")).json();
  531. let lastCat=null, group=null, ps=$("#preset");
  532. (p.presets||[]).forEach(pr=>{
  533. if(pr.category!==lastCat){ group=document.createElement("optgroup");
  534. group.label=pr.category; ps.appendChild(group); lastCat=pr.category; }
  535. const o=document.createElement("option"); o.value=pr.name; o.textContent=pr.name;
  536. group.appendChild(o);
  537. });
  538. }catch(e){ toast("Backend unreachable: "+e.message); }
  539. }
  540. /* ---------------- uploads ---------------- */
  541. let SELECTED = null;
  542. const drop=$("#drop"), fi=$("#fileInput");
  543. drop.onclick=()=>fi.click();
  544. drop.onkeydown=e=>{ if(e.key==="Enter"||e.key===" ") fi.click(); };
  545. ["dragover","dragenter"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add("hover");}));
  546. ["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove("hover");}));
  547. drop.addEventListener("drop",e=>{ if(e.dataTransfer.files.length) upload(e.dataTransfer.files[0]); });
  548. fi.onchange=()=>{ if(fi.files.length) upload(fi.files[0]); fi.value=""; };
  549. let FREE_BYTES = null;
  550. function upload(file){
  551. if(FREE_BYTES!==null && file.size > FREE_BYTES - 256*1024*1024){
  552. toast(`Not enough space: ${fmtSize(file.size)} file, ${fmtSize(FREE_BYTES)} free on the container`);
  553. return;
  554. }
  555. const xhr=new XMLHttpRequest();
  556. $("#upbar").classList.add("show");
  557. const fill=$("#upfill"), txt=$("#uptext");
  558. xhr.upload.onprogress=e=>{
  559. if(e.lengthComputable){
  560. const pct=(e.loaded/e.total*100);
  561. fill.style.width=pct.toFixed(1)+"%";
  562. txt.textContent=`Uploading ${file.name} — ${pct.toFixed(1)}% of ${fmtSize(e.total)}`;
  563. }};
  564. xhr.onload=()=>{
  565. $("#upbar").classList.remove("show"); fill.style.width="0";
  566. if(xhr.status===200){
  567. let res={}; try{ res=JSON.parse(xhr.responseText) }catch{}
  568. SELECTED = res.upload_id || SELECTED; // freshly poured = active source
  569. toast("Uploaded "+file.name+" — selected as source");
  570. refreshUploads(); updateStartState(); previewCmd();
  571. }
  572. else if(xhr.status===401){ markAuthNeeded(); toast("Upload needs an API token"); }
  573. else{
  574. let msg=""; try{ msg=JSON.parse(xhr.responseText).error }catch{}
  575. toast(msg || "Upload failed ("+xhr.status+")");
  576. refreshUploads(); // pick up fresh storage numbers after a space failure
  577. }
  578. };
  579. xhr.onerror=()=>{ $("#upbar").classList.remove("show"); toast("Upload failed — network error"); };
  580. xhr.open("POST","/api/upload?filename="+encodeURIComponent(file.name));
  581. if(API_TOKEN) xhr.setRequestHeader("X-API-Token",API_TOKEN);
  582. xhr.send(file); // raw body stream — no multipart, no server-side spooling
  583. }
  584. async function refreshUploads(){
  585. try{
  586. const d = await (await api("/api/uploads")).json();
  587. if(d.storage){
  588. FREE_BYTES = d.storage.free;
  589. const s=$("#storage"); s.hidden=false;
  590. const usedPct = d.storage.total ? (d.storage.used/d.storage.total*100) : 0;
  591. $("#storefill").style.width = usedPct.toFixed(1)+"%";
  592. $("#storetext").textContent =
  593. `Container storage: ${fmtSize(d.storage.free)} free of ${fmtSize(d.storage.total)}`;
  594. s.classList.toggle("low", d.storage.free < Math.max(d.storage.total*0.08, 1e9));
  595. }
  596. const list=$("#srcList"); list.innerHTML="";
  597. d.uploads.forEach(u=>{
  598. const row=document.createElement("div");
  599. row.className="src"+(SELECTED===u.upload_id?" sel":"");
  600. row.innerHTML=`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z" fill="var(--gold)"/></svg>
  601. <span class="name">${esc(u.filename)}</span>
  602. <span class="meta">${fmtSize(u.size)}</span>
  603. <button class="del" title="Delete upload" aria-label="Delete upload">✕</button>`;
  604. row.onclick=e=>{
  605. if(e.target.classList.contains("del")) return;
  606. SELECTED=u.upload_id; refreshUploads(); updateStartState(); previewCmd();
  607. };
  608. row.querySelector(".del").onclick=async ()=>{
  609. await api("/api/uploads/"+encodeURIComponent(u.upload_id),{method:"DELETE"});
  610. if(SELECTED===u.upload_id){SELECTED=null;updateStartState();}
  611. refreshUploads(); toast("Upload deleted");
  612. };
  613. list.appendChild(row);
  614. });
  615. if(!d.uploads.length) list.innerHTML='<div class="empty">No sources uploaded yet.</div>';
  616. if(SELECTED && !d.uploads.some(u=>u.upload_id===SELECTED)){
  617. SELECTED=null; updateStartState(); // file vanished server-side — don't pretend it's active
  618. }
  619. }catch(e){
  620. $("#srcList").innerHTML =
  621. `<div class="srcerr">Can't list sources: ${esc(e.message)}${AUTH_NEEDED?" — set your token via the key button above.":""}</div>`;
  622. }
  623. }
  624. function updateStartState(){
  625. $("#startBtn").disabled=!SELECTED;
  626. $("#startHint").innerHTML = SELECTED
  627. ? `<span class="active-src">Active source: <b>${esc(SELECTED.split("_").slice(1).join("_")||SELECTED)}</b></span>`
  628. : "Select a source above first.";
  629. }
  630. /* ---------------- scan ---------------- */
  631. $("#scanBtn").onclick=async ()=>{
  632. if(!SELECTED){ toast("Select a source first"); return; }
  633. $("#scanOut").textContent="Scanning…";
  634. try{
  635. const d = await (await api("/api/scan/"+encodeURIComponent(SELECTED))).json();
  636. const lines=(d.TitleList||[]).map(t=>
  637. `Title ${t.Index}: ${t.Duration?`${t.Duration.Hours}:${String(t.Duration.Minutes).padStart(2,"0")}:${String(t.Duration.Seconds).padStart(2,"0")}`:""} · `+
  638. `${t.Geometry?t.Geometry.Width+"×"+t.Geometry.Height:""} · `+
  639. `${(t.AudioList||[]).length} audio · ${(t.SubtitleList||[]).length} subs`);
  640. $("#scanOut").textContent=lines.join("\n")||"No titles found.";
  641. }catch(e){ $("#scanOut").textContent="Scan failed: "+e.message; }
  642. };
  643. /* ---------------- options collection ---------------- */
  644. const TEXT_KEYS=["title","chapters","angle","min_duration","start_at","stop_at","previews",
  645. "start_at_preview","encoder","encoder_preset","encoder_tune","encoder_profile","encoder_level",
  646. "quality","vb","rate","encopts","audio","audio_lang_list","aencoder","audio_copy_mask",
  647. "audio_fallback","ab","aq","ac","mixdown","normalize_mix","arate","drc","gain","adither","aname",
  648. "subtitle","subtitle_lang_list","subtitle_forced","subtitle_burned","subtitle_default","subname",
  649. "native_language","srt_file","srt_codeset","srt_offset","srt_lang","srt_burn",
  650. "width","height","maxWidth","maxHeight","crop","crop_mode","display_width","pixel_aspect",
  651. "modulus","color_matrix","comb_detect","deinterlace","decomb","detelecine","hqdn3d","nlmeans",
  652. "nlmeans_tune","chroma_smooth","unsharp","lapsharp","deblock","rotate","pad","colorspace",
  653. "output_name","extra_args","preset_import_file"];
  654. const BOOL_KEYS=["main_feature","markers","optimize","align_av","inline_parameter_sets",
  655. "two_pass","turbo","all_audio","first_audio","all_subtitles","first_subtitle","native_dub",
  656. "keep_display_aspect","grayscale"];
  657. const FILTER_KEYS=new Set(["comb_detect","deinterlace","decomb","detelecine","hqdn3d","nlmeans",
  658. "chroma_smooth","unsharp","lapsharp","deblock","rotate","subtitle_forced","subtitle_burned",
  659. "subtitle_default","srt_burn"]);
  660. function collectOptions(){
  661. const o={};
  662. const preset=$("#preset").value; if(preset) o.preset=preset;
  663. o.format=$("#o_format").value;
  664. for(const k of TEXT_KEYS){
  665. const el=document.getElementById("o_"+k); if(!el) continue;
  666. let v=el.value.trim(); if(v==="") continue;
  667. if(FILTER_KEYS.has(k) && v.toLowerCase()==="default") v=true;
  668. o[k]=v;
  669. }
  670. for(const k of BOOL_KEYS){
  671. const el=document.getElementById("o_"+k);
  672. if(el && el.checked) o[k]=true;
  673. }
  674. const fr=$("#o_fr_mode").value; if(fr) o[fr]=true;
  675. const an=$("#o_anamorphic").value; if(an) o[an]=true;
  676. if($("#o_hwdec").checked) o.enable_hw_decoding =
  677. CAPS.nvenc ? "nvdec" : (CAPS.qsv ? "qsv" : "videotoolbox");
  678. return o;
  679. }
  680. function previewCmd(){
  681. const o=collectOptions(), parts=["HandBrakeCLI --json -i <source> -o <output>"];
  682. for(const [k,v] of Object.entries(o)){
  683. if(k==="output_name") continue;
  684. const flag="--"+k.replace(/_/g,"-");
  685. if(v===true) parts.push(flag);
  686. else if(k==="extra_args") parts.push(v);
  687. else parts.push(flag+" "+v);
  688. }
  689. $("#cmdPreview").value=parts.join(" ");
  690. }
  691. document.addEventListener("input",previewCmd);
  692. document.addEventListener("change",previewCmd);
  693. /* ---------------- jobs ---------------- */
  694. $("#startBtn").onclick=async ()=>{
  695. if(!SELECTED) return;
  696. try{
  697. await api("/api/jobs",{method:"POST",
  698. headers:{"Content-Type":"application/json"},
  699. body:JSON.stringify({upload_id:SELECTED,options:collectOptions()})});
  700. toast("Job queued"); refreshJobs();
  701. }catch(e){ toast("Could not queue job: "+e.message); }
  702. };
  703. const openLogs=new Set();
  704. async function refreshJobs(){
  705. try{
  706. const d=await (await api("/api/jobs")).json();
  707. const box=$("#jobs");
  708. if(!d.jobs.length){ box.innerHTML='<div class="empty">Nothing brewing yet.</div>'; return; }
  709. box.innerHTML="";
  710. d.jobs.forEach(j=>{
  711. const el=document.createElement("div"); el.className="job"; el.dataset.id=j.id;
  712. const running=["running","scanning","queued"].includes(j.status);
  713. const stats=[];
  714. if(j.fps) stats.push(j.fps+" fps");
  715. if(j.eta) stats.push("ETA "+fmtEta(j.eta));
  716. if(j.pass&&j.pass>0) stats.push("pass "+j.pass);
  717. el.innerHTML=`
  718. <div class="row">
  719. <span class="fname">${esc(j.filename)}</span>
  720. <span class="status ${j.status}">${j.status}</span>
  721. <span class="stats">${stats.map(esc).join(" · ")}</span>
  722. </div>
  723. <div class="bar ${running?"":"static"}"><i style="width:${j.progress||0}%"></i></div>
  724. ${j.error?`<div class="errline">${esc(j.error)}</div>`:""}
  725. <div class="btns">
  726. ${j.status==="done"?`<a class="btn sm gold" href="/api/download/${j.id}${API_TOKEN?"?token="+encodeURIComponent(API_TOKEN):""}" download>Download</a>`:""}
  727. ${running?`<button class="btn sm danger" data-act="cancel">Cancel</button>`:""}
  728. <button class="btn sm" data-act="log">${openLogs.has(j.id)?"Hide log":"Log"}</button>
  729. ${!running?`<button class="btn sm danger" data-act="del">Delete</button>`:""}
  730. </div>
  731. <pre class="logbox ${openLogs.has(j.id)?"show":""}"></pre>`;
  732. el.querySelectorAll("[data-act]").forEach(b=>b.onclick=async ()=>{
  733. const act=b.dataset.act;
  734. if(act==="cancel"){ await api(`/api/jobs/${j.id}/cancel`,{method:"POST"}); toast("Cancelling…"); }
  735. if(act==="del"){ await api(`/api/jobs/${j.id}`,{method:"DELETE"}); toast("Job deleted"); }
  736. if(act==="log"){ openLogs.has(j.id)?openLogs.delete(j.id):openLogs.add(j.id); }
  737. refreshJobs();
  738. });
  739. if(openLogs.has(j.id)){
  740. api(`/api/jobs/${j.id}/log`).then(r=>r.text()).then(t=>{
  741. const lb=el.querySelector(".logbox"); lb.textContent=t; lb.scrollTop=lb.scrollHeight;
  742. }).catch(()=>{});
  743. }
  744. box.appendChild(el);
  745. });
  746. }catch(e){
  747. if(AUTH_NEEDED) $("#jobs").innerHTML =
  748. '<div class="srcerr">Token required to view the queue — set it via the key button above.</div>';
  749. }
  750. }
  751. /* ---------------- boot ---------------- */
  752. function bootData(){ loadMeta(); refreshUploads(); refreshJobs(); }
  753. bootData(); updateStartState(); previewCmd();
  754. setInterval(()=>{ if(!AUTH_NEEDED) refreshJobs(); },2000);
  755. setInterval(()=>{ if(!AUTH_NEEDED) refreshUploads(); },15000);
  756. </script>
  757. </body>
  758. </html>