index.html 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  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. @media(max-width:640px){.job .stats{margin-left:0;width:100%}}
  150. </style>
  151. </head>
  152. <body>
  153. <div class="wrap">
  154. <header>
  155. <div class="logo">
  156. <!-- coffee-ring / reel mark -->
  157. <svg width="42" height="42" viewBox="0 0 42 42" fill="none" aria-hidden="true">
  158. <circle cx="21" cy="21" r="17" stroke="var(--gold)" stroke-width="3.5"/>
  159. <circle cx="21" cy="21" r="9" stroke="var(--gold)" stroke-width="2" stroke-dasharray="4 5"/>
  160. <circle cx="21" cy="21" r="3" fill="var(--gold)"/>
  161. </svg>
  162. <div>
  163. <h1>WeBrake</h1>
  164. <div class="tag">HandBrake, poured over the web</div>
  165. </div>
  166. </div>
  167. <div class="chips" id="chips">
  168. <span class="chip" id="chip-hb">HandBrake …</span>
  169. <button class="theme-btn" id="themeBtn" title="Toggle dark roast" aria-label="Toggle theme">
  170. <svg id="themeIcon" width="18" height="18" viewBox="0 0 24 24" fill="none"
  171. stroke="currentColor" stroke-width="2" stroke-linecap="round"></svg>
  172. </button>
  173. </div>
  174. </header>
  175. <!-- ============ upload ============ -->
  176. <section class="card">
  177. <h2>1 · Pour in your media</h2>
  178. <div class="sub">Drop a video file to upload it to the container for remuxing or re-encoding.</div>
  179. <div class="drop" id="drop" tabindex="0" role="button" aria-label="Upload a video file">
  180. <strong>Drop a file here</strong> or click to browse
  181. <div class="hint">MKV, MP4, AVI, TS, MOV, WebM — anything HandBrake can scan.</div>
  182. </div>
  183. <input type="file" id="fileInput" hidden>
  184. <div class="upbar" id="upbar">
  185. <div class="bar"><i id="upfill"></i></div>
  186. <div class="hint" id="uptext">Uploading…</div>
  187. </div>
  188. <div class="src-list" id="srcList"></div>
  189. </section>
  190. <!-- ============ options ============ -->
  191. <section class="card">
  192. <h2>2 · Dial in the brew</h2>
  193. <div class="sub">Every HandBrake feature is here — pick a preset, fine-tune the groups, or drop raw flags in Advanced.</div>
  194. <div class="grid" style="margin-bottom:4px">
  195. <div class="field">
  196. <label for="preset">Preset</label>
  197. <select id="preset"><option value="">(none — manual settings)</option></select>
  198. </div>
  199. <div class="field">
  200. <label for="o_format">Container</label>
  201. <select id="o_format">
  202. <option value="av_mkv">MKV (av_mkv)</option>
  203. <option value="av_mp4">MP4 (av_mp4)</option>
  204. <option value="av_webm">WebM (av_webm)</option>
  205. </select>
  206. </div>
  207. <div class="field">
  208. <label for="o_output_name">Output name (no extension)</label>
  209. <input type="text" id="o_output_name" placeholder="defaults to source name">
  210. </div>
  211. </div>
  212. <div class="tabs" id="tabs">
  213. <button class="tab on" data-pane="p-src">Source</button>
  214. <button class="tab" data-pane="p-vid">Video</button>
  215. <button class="tab" data-pane="p-aud">Audio</button>
  216. <button class="tab" data-pane="p-sub">Subtitles</button>
  217. <button class="tab" data-pane="p-pic">Picture</button>
  218. <button class="tab" data-pane="p-fil">Filters</button>
  219. <button class="tab" data-pane="p-adv">Advanced</button>
  220. </div>
  221. <!-- Source -->
  222. <div class="pane on" id="p-src"><div class="grid">
  223. <div class="field"><label>Title #</label><input type="number" id="o_title" min="0" placeholder="auto"></div>
  224. <div class="field"><label>Chapters</label><input type="text" id="o_chapters" placeholder="e.g. 1-3"></div>
  225. <div class="field"><label>Angle</label><input type="number" id="o_angle" min="1" placeholder=""></div>
  226. <div class="field"><label>Min duration (s)</label><input type="number" id="o_min_duration" placeholder=""></div>
  227. <div class="field"><label>Start at</label><input type="text" id="o_start_at" placeholder="seconds:30 / frames:100 / duration:00:01:00"></div>
  228. <div class="field"><label>Stop at</label><input type="text" id="o_stop_at" placeholder="same units as start"></div>
  229. <div class="field"><label>Previews</label><input type="text" id="o_previews" placeholder="e.g. 30:1"></div>
  230. <div class="field"><label>Start at preview #</label><input type="number" id="o_start_at_preview" placeholder=""></div>
  231. <div class="field wide">
  232. <label class="check"><input type="checkbox" id="o_main_feature"> Main feature title only (--main-feature)</label>
  233. <label class="check"><input type="checkbox" id="o_markers" checked> Chapter markers (--markers)</label>
  234. <label class="check"><input type="checkbox" id="o_optimize"> Web optimized / fast start (--optimize)</label>
  235. <label class="check"><input type="checkbox" id="o_align_av"> Align A/V start (--align-av)</label>
  236. <label class="check"><input type="checkbox" id="o_inline_parameter_sets"> Inline parameter sets (--inline-parameter-sets)</label>
  237. </div>
  238. <div class="field wide" id="scanBox">
  239. <button class="btn sm" id="scanBtn" type="button">Scan source for titles &amp; tracks</button>
  240. <div class="hint mono" id="scanOut"></div>
  241. </div>
  242. </div></div>
  243. <!-- Video -->
  244. <div class="pane" id="p-vid"><div class="grid">
  245. <div class="field"><label>Encoder</label><select id="o_encoder"><option value="">(preset default)</option></select>
  246. <div class="hint" id="hwHint"></div></div>
  247. <div class="field"><label>Encoder preset</label><input type="text" id="o_encoder_preset" placeholder="e.g. medium / p5 / quality"></div>
  248. <div class="field"><label>Encoder tune</label><input type="text" id="o_encoder_tune" placeholder="e.g. film, animation"></div>
  249. <div class="field"><label>Profile</label><input type="text" id="o_encoder_profile" placeholder="e.g. main, high, main10"></div>
  250. <div class="field"><label>Level</label><input type="text" id="o_encoder_level" placeholder="e.g. 4.1"></div>
  251. <div class="field"><label>Quality (CQ/RF)</label><input type="number" id="o_quality" step="0.25" placeholder="e.g. 22"></div>
  252. <div class="field"><label>Bitrate kb/s (--vb)</label><input type="number" id="o_vb" placeholder="overrides quality"></div>
  253. <div class="field"><label>Framerate (--rate)</label><input type="text" id="o_rate" placeholder="e.g. 23.976, 30"></div>
  254. <div class="field"><label>Framerate mode</label>
  255. <select id="o_fr_mode"><option value="">source default</option>
  256. <option value="cfr">Constant (--cfr)</option>
  257. <option value="vfr">Variable (--vfr)</option>
  258. <option value="pfr">Peak (--pfr)</option></select></div>
  259. <div class="field wide"><label>Extra encoder options (--encopts)</label>
  260. <input type="text" class="mono" id="o_encopts" placeholder="key=value:key=value"></div>
  261. <div class="field wide">
  262. <label class="check"><input type="checkbox" id="o_two_pass"> Two-pass encode (--two-pass)</label>
  263. <label class="check"><input type="checkbox" id="o_turbo"> Turbo first pass (--turbo)</label>
  264. <label class="check"><input type="checkbox" id="o_hwdec"> Hardware decoding (--enable-hw-decoding)</label>
  265. </div>
  266. </div></div>
  267. <!-- Audio -->
  268. <div class="pane" id="p-aud"><div class="grid">
  269. <div class="field"><label>Tracks (--audio)</label><input type="text" id="o_audio" placeholder="e.g. 1,2 or leave blank"></div>
  270. <div class="field"><label>Language list</label><input type="text" id="o_audio_lang_list" placeholder="e.g. eng,jpn"></div>
  271. <div class="field"><label>Encoder(s) (--aencoder)</label><input type="text" id="o_aencoder" placeholder="e.g. copy, av_aac, opus, flac"></div>
  272. <div class="field"><label>Copy mask</label><input type="text" id="o_audio_copy_mask" placeholder="e.g. aac,ac3,dtshd,truehd"></div>
  273. <div class="field"><label>Fallback encoder</label><input type="text" id="o_audio_fallback" placeholder="e.g. av_aac"></div>
  274. <div class="field"><label>Bitrate(s) kb/s (--ab)</label><input type="text" id="o_ab" placeholder="e.g. 160,256"></div>
  275. <div class="field"><label>Quality(ies) (--aq)</label><input type="text" id="o_aq" placeholder=""></div>
  276. <div class="field"><label>Compression (--ac)</label><input type="text" id="o_ac" placeholder=""></div>
  277. <div class="field"><label>Mixdown</label><input type="text" id="o_mixdown" placeholder="e.g. stereo, 5point1"></div>
  278. <div class="field"><label>Normalize mix</label><input type="text" id="o_normalize_mix" placeholder="0 or 1"></div>
  279. <div class="field"><label>Samplerate kHz (--arate)</label><input type="text" id="o_arate" placeholder="e.g. 48"></div>
  280. <div class="field"><label>DRC (--drc)</label><input type="text" id="o_drc" placeholder="1.0–4.0"></div>
  281. <div class="field"><label>Gain dB</label><input type="text" id="o_gain" placeholder="e.g. 2"></div>
  282. <div class="field"><label>Dither (--adither)</label><input type="text" id="o_adither" placeholder="auto / none / …"></div>
  283. <div class="field wide"><label>Track name(s) (--aname)</label><input type="text" id="o_aname" placeholder="comma-separated"></div>
  284. <div class="field wide">
  285. <label class="check"><input type="checkbox" id="o_all_audio"> Include all audio tracks (--all-audio)</label>
  286. <label class="check"><input type="checkbox" id="o_first_audio"> First audio track only (--first-audio)</label>
  287. </div>
  288. </div></div>
  289. <!-- Subtitles -->
  290. <div class="pane" id="p-sub"><div class="grid">
  291. <div class="field"><label>Tracks (--subtitle)</label><input type="text" id="o_subtitle" placeholder="e.g. 1,2 / scan"></div>
  292. <div class="field"><label>Language list</label><input type="text" id="o_subtitle_lang_list" placeholder="e.g. eng"></div>
  293. <div class="field"><label>Forced only (--subtitle-forced)</label><input type="text" id="o_subtitle_forced" placeholder="track #s or blank"></div>
  294. <div class="field"><label>Burn in (--subtitle-burned)</label><input type="text" id="o_subtitle_burned" placeholder="track # / native / none"></div>
  295. <div class="field"><label>Default track</label><input type="text" id="o_subtitle_default" placeholder="track # / none"></div>
  296. <div class="field"><label>Track name(s) (--subname)</label><input type="text" id="o_subname" placeholder=""></div>
  297. <div class="field"><label>Native language</label><input type="text" id="o_native_language" placeholder="e.g. eng"></div>
  298. <div class="field wide"><label>External SRT file path (--srt-file)</label>
  299. <input type="text" class="mono" id="o_srt_file" placeholder="/var/lib/webrake/uploads/…"></div>
  300. <div class="field"><label>SRT codeset</label><input type="text" id="o_srt_codeset" placeholder="UTF-8"></div>
  301. <div class="field"><label>SRT offset ms</label><input type="text" id="o_srt_offset" placeholder=""></div>
  302. <div class="field"><label>SRT language</label><input type="text" id="o_srt_lang" placeholder="e.g. eng"></div>
  303. <div class="field"><label>SRT burn (track #)</label><input type="text" id="o_srt_burn" placeholder=""></div>
  304. <div class="field wide">
  305. <label class="check"><input type="checkbox" id="o_all_subtitles"> Include all subtitles (--all-subtitles)</label>
  306. <label class="check"><input type="checkbox" id="o_first_subtitle"> First subtitle only (--first-subtitle)</label>
  307. <label class="check"><input type="checkbox" id="o_native_dub"> Prefer native-language dub (--native-dub)</label>
  308. </div>
  309. </div></div>
  310. <!-- Picture -->
  311. <div class="pane" id="p-pic"><div class="grid">
  312. <div class="field"><label>Width</label><input type="number" id="o_width" placeholder=""></div>
  313. <div class="field"><label>Height</label><input type="number" id="o_height" placeholder=""></div>
  314. <div class="field"><label>Max width</label><input type="number" id="o_maxWidth" placeholder=""></div>
  315. <div class="field"><label>Max height</label><input type="number" id="o_maxHeight" placeholder=""></div>
  316. <div class="field"><label>Crop T:B:L:R</label><input type="text" id="o_crop" placeholder="e.g. 0:0:0:0"></div>
  317. <div class="field"><label>Crop mode</label>
  318. <select id="o_crop_mode"><option value="">default</option>
  319. <option>auto</option><option>conservative</option><option>none</option></select></div>
  320. <div class="field"><label>Anamorphic</label>
  321. <select id="o_anamorphic"><option value="">default</option>
  322. <option value="auto_anamorphic">auto</option>
  323. <option value="loose_anamorphic">loose</option>
  324. <option value="custom_anamorphic">custom</option>
  325. <option value="non_anamorphic">none</option></select></div>
  326. <div class="field"><label>Display width</label><input type="number" id="o_display_width" placeholder=""></div>
  327. <div class="field"><label>Pixel aspect X:Y</label><input type="text" id="o_pixel_aspect" placeholder="e.g. 1:1"></div>
  328. <div class="field"><label>Modulus</label><input type="number" id="o_modulus" placeholder="2"></div>
  329. <div class="field"><label>Color matrix</label><input type="text" id="o_color_matrix" placeholder="709 / 601 / 2020 / …"></div>
  330. <div class="field wide">
  331. <label class="check"><input type="checkbox" id="o_keep_display_aspect"> Keep display aspect (--keep-display-aspect)</label>
  332. </div>
  333. </div></div>
  334. <!-- Filters -->
  335. <div class="pane" id="p-fil"><div class="grid">
  336. <div class="field"><label>Comb detect</label><input type="text" id="o_comb_detect" placeholder="blank=off · 'default' or settings"></div>
  337. <div class="field"><label>Deinterlace</label><input type="text" id="o_deinterlace" placeholder="'default' or preset/settings"></div>
  338. <div class="field"><label>Decomb</label><input type="text" id="o_decomb" placeholder="'default' or settings"></div>
  339. <div class="field"><label>Detelecine</label><input type="text" id="o_detelecine" placeholder="'default' or settings"></div>
  340. <div class="field"><label>Denoise hqdn3d</label><input type="text" id="o_hqdn3d" placeholder="light / medium / strong / custom"></div>
  341. <div class="field"><label>Denoise NLMeans</label><input type="text" id="o_nlmeans" placeholder="ultralight … strong"></div>
  342. <div class="field"><label>NLMeans tune</label><input type="text" id="o_nlmeans_tune" placeholder="film / grain / …"></div>
  343. <div class="field"><label>Chroma smooth</label><input type="text" id="o_chroma_smooth" placeholder=""></div>
  344. <div class="field"><label>Unsharp</label><input type="text" id="o_unsharp" placeholder="light / medium / strong"></div>
  345. <div class="field"><label>Lapsharp</label><input type="text" id="o_lapsharp" placeholder=""></div>
  346. <div class="field"><label>Deblock</label><input type="text" id="o_deblock" placeholder=""></div>
  347. <div class="field"><label>Rotate</label><input type="text" id="o_rotate" placeholder="angle=90:hflip=0"></div>
  348. <div class="field"><label>Pad</label><input type="text" id="o_pad" placeholder="W:H:color:X:Y"></div>
  349. <div class="field"><label>Colorspace</label><input type="text" id="o_colorspace" placeholder="bt709 / bt2020 / …"></div>
  350. <div class="field wide">
  351. <label class="check"><input type="checkbox" id="o_grayscale"> Grayscale (--grayscale)</label>
  352. </div>
  353. <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>
  354. </div></div>
  355. <!-- Advanced -->
  356. <div class="pane" id="p-adv"><div class="grid">
  357. <div class="field wide"><label>Raw HandBrakeCLI flags — everything else lives here</label>
  358. <textarea id="o_extra_args" class="mono" placeholder="--preset-import-file /path/custom.json --subtitle-lang-list eng --queue-import-file …"></textarea>
  359. <div class="hint">Appended verbatim to the command line, so the entire HandBrake flag surface is available. See <code>HandBrakeCLI --help</code>.</div>
  360. </div>
  361. <div class="field wide"><label>Command preview</label>
  362. <textarea id="cmdPreview" class="mono" readonly rows="3"></textarea></div>
  363. </div></div>
  364. <div class="actions">
  365. <button class="btn gold" id="startBtn">Start encode</button>
  366. <span class="hint" id="startHint">Select a source above first.</span>
  367. </div>
  368. </section>
  369. <!-- ============ queue ============ -->
  370. <section class="card">
  371. <h2>3 · The counter</h2>
  372. <div class="sub">Live queue — watch progress, then download the finished cup back to this PC.</div>
  373. <div id="jobs"><div class="empty">Nothing brewing yet.</div></div>
  374. </section>
  375. <footer>WeBrake · <span id="verline"></span> · <a href="https://gogs.av2x.dev/av2x/WeBrake">source</a></footer>
  376. </div>
  377. <div class="toast" id="toast"></div>
  378. <script>
  379. "use strict";
  380. /* ---------------- helpers ---------------- */
  381. const $ = s => document.querySelector(s);
  382. const $$ = s => [...document.querySelectorAll(s)];
  383. let API_TOKEN = localStorage.getItem("webrake_token") || "";
  384. function headers(extra){ const h = extra||{}; if(API_TOKEN) h["X-API-Token"]=API_TOKEN; return h; }
  385. async function api(path, opts={}){
  386. opts.headers = headers(opts.headers);
  387. const r = await fetch(path, opts);
  388. if(r.status===401){
  389. API_TOKEN = prompt("This WeBrake requires an API token (see token.json in /opt/webrake):")||"";
  390. localStorage.setItem("webrake_token", API_TOKEN);
  391. if(API_TOKEN) return api(path, opts);
  392. }
  393. if(!r.ok){ let e={}; try{e=await r.json()}catch{} throw new Error(e.error||r.statusText); }
  394. return r;
  395. }
  396. function toast(msg){ const t=$("#toast"); t.textContent=msg; t.classList.add("show");
  397. clearTimeout(t._h); t._h=setTimeout(()=>t.classList.remove("show"),2600); }
  398. function fmtSize(b){ if(b>1e9)return (b/1e9).toFixed(2)+" GB"; if(b>1e6)return (b/1e6).toFixed(1)+" MB";
  399. return (b/1e3).toFixed(0)+" KB"; }
  400. function fmtEta(s){ if(!s)return ""; const h=~~(s/3600),m=~~(s%3600/60),ss=~~(s%60);
  401. return (h?h+"h ":"")+(m?m+"m ":"")+ss+"s"; }
  402. function esc(x){ const d=document.createElement("div"); d.textContent=x??""; return d.innerHTML; }
  403. /* ---------------- theme ---------------- */
  404. 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"/>';
  405. const moonPath = '<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>';
  406. function setTheme(t){
  407. document.documentElement.dataset.theme=t;
  408. localStorage.setItem("webrake_theme",t);
  409. $("#themeIcon").innerHTML = t==="dark" ? sunPath : moonPath;
  410. }
  411. setTheme(localStorage.getItem("webrake_theme") ||
  412. (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"));
  413. $("#themeBtn").onclick = () =>
  414. setTheme(document.documentElement.dataset.theme==="dark" ? "light" : "dark");
  415. /* ---------------- tabs ---------------- */
  416. $$("#tabs .tab").forEach(t => t.onclick = () => {
  417. $$("#tabs .tab").forEach(x=>x.classList.remove("on"));
  418. $$(".pane").forEach(x=>x.classList.remove("on"));
  419. t.classList.add("on"); $("#"+t.dataset.pane).classList.add("on");
  420. });
  421. /* ---------------- capabilities / presets ---------------- */
  422. let CAPS = {};
  423. async function loadMeta(){
  424. try{
  425. const v = await (await api("/api/version")).json();
  426. $("#verline").textContent = "v"+v.webrake+" · HandBrake "+(v.handbrake||"not found");
  427. CAPS = await (await api("/api/capabilities")).json();
  428. $("#chip-hb").textContent = CAPS.handbrake_found
  429. ? "HandBrake "+(CAPS.handbrake_version||"") : "HandBrake missing!";
  430. const chips = $("#chips"), btn = $("#themeBtn");
  431. for(const [k,label] of [["qsv","Intel QSV"],["nvenc","NVENC"],["vce","AMD VCE"],["vaapi","VAAPI"]]){
  432. if(CAPS[k]){ const c=document.createElement("span"); c.className="chip hw";
  433. c.textContent="⚡ "+label; chips.insertBefore(c,btn); }
  434. }
  435. const sel = $("#o_encoder");
  436. (CAPS.encoders||[]).forEach(e=>{
  437. const o=document.createElement("option"); o.value=e;
  438. o.textContent=(CAPS.hw_encoders||[]).includes(e) ? "⚡ "+e+" (GPU)" : e;
  439. sel.appendChild(o);
  440. });
  441. const hw=(CAPS.hw_encoders||[]);
  442. $("#hwHint").textContent = hw.length
  443. ? "GPU passthru detected — ⚡ encoders run on hardware."
  444. : "No hardware encoders detected; software encoders only.";
  445. const p = await (await api("/api/presets")).json();
  446. let lastCat=null, group=null, ps=$("#preset");
  447. (p.presets||[]).forEach(pr=>{
  448. if(pr.category!==lastCat){ group=document.createElement("optgroup");
  449. group.label=pr.category; ps.appendChild(group); lastCat=pr.category; }
  450. const o=document.createElement("option"); o.value=pr.name; o.textContent=pr.name;
  451. group.appendChild(o);
  452. });
  453. }catch(e){ toast("Backend unreachable: "+e.message); }
  454. }
  455. /* ---------------- uploads ---------------- */
  456. let SELECTED = null;
  457. const drop=$("#drop"), fi=$("#fileInput");
  458. drop.onclick=()=>fi.click();
  459. drop.onkeydown=e=>{ if(e.key==="Enter"||e.key===" ") fi.click(); };
  460. ["dragover","dragenter"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add("hover");}));
  461. ["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove("hover");}));
  462. drop.addEventListener("drop",e=>{ if(e.dataTransfer.files.length) upload(e.dataTransfer.files[0]); });
  463. fi.onchange=()=>{ if(fi.files.length) upload(fi.files[0]); fi.value=""; };
  464. function upload(file){
  465. const xhr=new XMLHttpRequest(), fd=new FormData(); fd.append("file",file);
  466. $("#upbar").classList.add("show");
  467. const fill=$("#upfill"), txt=$("#uptext");
  468. xhr.upload.onprogress=e=>{
  469. if(e.lengthComputable){
  470. const pct=(e.loaded/e.total*100);
  471. fill.style.width=pct.toFixed(1)+"%";
  472. txt.textContent=`Uploading ${file.name} — ${pct.toFixed(1)}% of ${fmtSize(e.total)}`;
  473. }};
  474. xhr.onload=()=>{
  475. $("#upbar").classList.remove("show"); fill.style.width="0";
  476. if(xhr.status===200){ toast("Uploaded "+file.name); refreshUploads(); }
  477. else toast("Upload failed ("+xhr.status+")");
  478. };
  479. xhr.onerror=()=>{ $("#upbar").classList.remove("show"); toast("Upload failed"); };
  480. xhr.open("POST","/api/upload");
  481. if(API_TOKEN) xhr.setRequestHeader("X-API-Token",API_TOKEN);
  482. xhr.send(fd);
  483. }
  484. async function refreshUploads(){
  485. try{
  486. const d = await (await api("/api/uploads")).json();
  487. const list=$("#srcList"); list.innerHTML="";
  488. d.uploads.forEach(u=>{
  489. const row=document.createElement("div");
  490. row.className="src"+(SELECTED===u.upload_id?" sel":"");
  491. 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>
  492. <span class="name">${esc(u.filename)}</span>
  493. <span class="meta">${fmtSize(u.size)}</span>
  494. <button class="del" title="Delete upload" aria-label="Delete upload">✕</button>`;
  495. row.onclick=e=>{
  496. if(e.target.classList.contains("del")) return;
  497. SELECTED=u.upload_id; refreshUploads(); updateStartState(); previewCmd();
  498. };
  499. row.querySelector(".del").onclick=async ()=>{
  500. await api("/api/uploads/"+encodeURIComponent(u.upload_id),{method:"DELETE"});
  501. if(SELECTED===u.upload_id){SELECTED=null;updateStartState();}
  502. refreshUploads(); toast("Upload deleted");
  503. };
  504. list.appendChild(row);
  505. });
  506. if(!d.uploads.length) list.innerHTML='<div class="empty">No sources uploaded yet.</div>';
  507. }catch(e){/* backend down; header toast already covers it */}
  508. }
  509. function updateStartState(){
  510. $("#startBtn").disabled=!SELECTED;
  511. $("#startHint").textContent=SELECTED?"":"Select a source above first.";
  512. }
  513. /* ---------------- scan ---------------- */
  514. $("#scanBtn").onclick=async ()=>{
  515. if(!SELECTED){ toast("Select a source first"); return; }
  516. $("#scanOut").textContent="Scanning…";
  517. try{
  518. const d = await (await api("/api/scan/"+encodeURIComponent(SELECTED))).json();
  519. const lines=(d.TitleList||[]).map(t=>
  520. `Title ${t.Index}: ${t.Duration?`${t.Duration.Hours}:${String(t.Duration.Minutes).padStart(2,"0")}:${String(t.Duration.Seconds).padStart(2,"0")}`:""} · `+
  521. `${t.Geometry?t.Geometry.Width+"×"+t.Geometry.Height:""} · `+
  522. `${(t.AudioList||[]).length} audio · ${(t.SubtitleList||[]).length} subs`);
  523. $("#scanOut").textContent=lines.join("\n")||"No titles found.";
  524. }catch(e){ $("#scanOut").textContent="Scan failed: "+e.message; }
  525. };
  526. /* ---------------- options collection ---------------- */
  527. const TEXT_KEYS=["title","chapters","angle","min_duration","start_at","stop_at","previews",
  528. "start_at_preview","encoder","encoder_preset","encoder_tune","encoder_profile","encoder_level",
  529. "quality","vb","rate","encopts","audio","audio_lang_list","aencoder","audio_copy_mask",
  530. "audio_fallback","ab","aq","ac","mixdown","normalize_mix","arate","drc","gain","adither","aname",
  531. "subtitle","subtitle_lang_list","subtitle_forced","subtitle_burned","subtitle_default","subname",
  532. "native_language","srt_file","srt_codeset","srt_offset","srt_lang","srt_burn",
  533. "width","height","maxWidth","maxHeight","crop","crop_mode","display_width","pixel_aspect",
  534. "modulus","color_matrix","comb_detect","deinterlace","decomb","detelecine","hqdn3d","nlmeans",
  535. "nlmeans_tune","chroma_smooth","unsharp","lapsharp","deblock","rotate","pad","colorspace",
  536. "output_name","extra_args","preset_import_file"];
  537. const BOOL_KEYS=["main_feature","markers","optimize","align_av","inline_parameter_sets",
  538. "two_pass","turbo","all_audio","first_audio","all_subtitles","first_subtitle","native_dub",
  539. "keep_display_aspect","grayscale"];
  540. const FILTER_KEYS=new Set(["comb_detect","deinterlace","decomb","detelecine","hqdn3d","nlmeans",
  541. "chroma_smooth","unsharp","lapsharp","deblock","rotate","subtitle_forced","subtitle_burned",
  542. "subtitle_default","srt_burn"]);
  543. function collectOptions(){
  544. const o={};
  545. const preset=$("#preset").value; if(preset) o.preset=preset;
  546. o.format=$("#o_format").value;
  547. for(const k of TEXT_KEYS){
  548. const el=document.getElementById("o_"+k); if(!el) continue;
  549. let v=el.value.trim(); if(v==="") continue;
  550. if(FILTER_KEYS.has(k) && v.toLowerCase()==="default") v=true;
  551. o[k]=v;
  552. }
  553. for(const k of BOOL_KEYS){
  554. const el=document.getElementById("o_"+k);
  555. if(el && el.checked) o[k]=true;
  556. }
  557. const fr=$("#o_fr_mode").value; if(fr) o[fr]=true;
  558. const an=$("#o_anamorphic").value; if(an) o[an]=true;
  559. if($("#o_hwdec").checked) o.enable_hw_decoding =
  560. CAPS.nvenc ? "nvdec" : (CAPS.qsv ? "qsv" : "videotoolbox");
  561. return o;
  562. }
  563. function previewCmd(){
  564. const o=collectOptions(), parts=["HandBrakeCLI --json -i <source> -o <output>"];
  565. for(const [k,v] of Object.entries(o)){
  566. if(k==="output_name") continue;
  567. const flag="--"+k.replace(/_/g,"-");
  568. if(v===true) parts.push(flag);
  569. else if(k==="extra_args") parts.push(v);
  570. else parts.push(flag+" "+v);
  571. }
  572. $("#cmdPreview").value=parts.join(" ");
  573. }
  574. document.addEventListener("input",previewCmd);
  575. document.addEventListener("change",previewCmd);
  576. /* ---------------- jobs ---------------- */
  577. $("#startBtn").onclick=async ()=>{
  578. if(!SELECTED) return;
  579. try{
  580. await api("/api/jobs",{method:"POST",
  581. headers:{"Content-Type":"application/json"},
  582. body:JSON.stringify({upload_id:SELECTED,options:collectOptions()})});
  583. toast("Job queued"); refreshJobs();
  584. }catch(e){ toast("Could not queue job: "+e.message); }
  585. };
  586. const openLogs=new Set();
  587. async function refreshJobs(){
  588. try{
  589. const d=await (await api("/api/jobs")).json();
  590. const box=$("#jobs");
  591. if(!d.jobs.length){ box.innerHTML='<div class="empty">Nothing brewing yet.</div>'; return; }
  592. box.innerHTML="";
  593. d.jobs.forEach(j=>{
  594. const el=document.createElement("div"); el.className="job"; el.dataset.id=j.id;
  595. const running=["running","scanning","queued"].includes(j.status);
  596. const stats=[];
  597. if(j.fps) stats.push(j.fps+" fps");
  598. if(j.eta) stats.push("ETA "+fmtEta(j.eta));
  599. if(j.pass&&j.pass>0) stats.push("pass "+j.pass);
  600. el.innerHTML=`
  601. <div class="row">
  602. <span class="fname">${esc(j.filename)}</span>
  603. <span class="status ${j.status}">${j.status}</span>
  604. <span class="stats">${stats.map(esc).join(" · ")}</span>
  605. </div>
  606. <div class="bar ${running?"":"static"}"><i style="width:${j.progress||0}%"></i></div>
  607. ${j.error?`<div class="errline">${esc(j.error)}</div>`:""}
  608. <div class="btns">
  609. ${j.status==="done"?`<a class="btn sm gold" href="/api/download/${j.id}${API_TOKEN?"?token="+encodeURIComponent(API_TOKEN):""}" download>Download</a>`:""}
  610. ${running?`<button class="btn sm danger" data-act="cancel">Cancel</button>`:""}
  611. <button class="btn sm" data-act="log">${openLogs.has(j.id)?"Hide log":"Log"}</button>
  612. ${!running?`<button class="btn sm danger" data-act="del">Delete</button>`:""}
  613. </div>
  614. <pre class="logbox ${openLogs.has(j.id)?"show":""}"></pre>`;
  615. el.querySelectorAll("[data-act]").forEach(b=>b.onclick=async ()=>{
  616. const act=b.dataset.act;
  617. if(act==="cancel"){ await api(`/api/jobs/${j.id}/cancel`,{method:"POST"}); toast("Cancelling…"); }
  618. if(act==="del"){ await api(`/api/jobs/${j.id}`,{method:"DELETE"}); toast("Job deleted"); }
  619. if(act==="log"){ openLogs.has(j.id)?openLogs.delete(j.id):openLogs.add(j.id); }
  620. refreshJobs();
  621. });
  622. if(openLogs.has(j.id)){
  623. api(`/api/jobs/${j.id}/log`).then(r=>r.text()).then(t=>{
  624. const lb=el.querySelector(".logbox"); lb.textContent=t; lb.scrollTop=lb.scrollHeight;
  625. }).catch(()=>{});
  626. }
  627. box.appendChild(el);
  628. });
  629. }catch(e){/* transient */}
  630. }
  631. /* ---------------- boot ---------------- */
  632. loadMeta(); refreshUploads(); refreshJobs(); updateStartState(); previewCmd();
  633. setInterval(refreshJobs,2000);
  634. setInterval(refreshUploads,15000);
  635. </script>
  636. </body>
  637. </html>