|
@@ -0,0 +1,676 @@
|
|
|
|
|
+<!DOCTYPE html>
|
|
|
|
|
+<html lang="en">
|
|
|
|
|
+<head>
|
|
|
|
|
+<meta charset="UTF-8">
|
|
|
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
+<title>WeBrake · HandBrake, poured over the web</title>
|
|
|
|
|
+<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
|
|
|
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
|
|
|
+<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">
|
|
|
|
|
+<style>
|
|
|
|
|
+:root{
|
|
|
|
|
+ --bg:#F6F0E2; /* cream */
|
|
|
|
|
+ --bg-raise:#FDFAF2;
|
|
|
|
|
+ --bg-sunken:#EFE7D3;
|
|
|
|
|
+ --ink:#33271B;
|
|
|
|
|
+ --ink-soft:#6E5E49;
|
|
|
|
|
+ --line:#E2D7BE;
|
|
|
|
|
+ --gold:#B98A1E; /* gold accent */
|
|
|
|
|
+ --gold-bright:#D9A82E;
|
|
|
|
|
+ --gold-soft:#F0E3C0;
|
|
|
|
|
+ --ok:#5E7C3A; --err:#A8442F; --warn:#B0761D;
|
|
|
|
|
+ --shadow:0 1px 2px rgba(51,39,27,.06),0 8px 24px rgba(51,39,27,.07);
|
|
|
|
|
+ --radius:14px;
|
|
|
|
|
+}
|
|
|
|
|
+[data-theme="dark"]{
|
|
|
|
|
+ --bg:#221610; /* dark roast */
|
|
|
|
|
+ --bg-raise:#2C1E15;
|
|
|
|
|
+ --bg-sunken:#1A100B;
|
|
|
|
|
+ --ink:#F0E4D0;
|
|
|
|
|
+ --ink-soft:#B39B7E;
|
|
|
|
|
+ --line:#3E2C1E;
|
|
|
|
|
+ --gold:#D4A937;
|
|
|
|
|
+ --gold-bright:#E8C158;
|
|
|
|
|
+ --gold-soft:#4A3617;
|
|
|
|
|
+ --ok:#8FB061; --err:#D3745E; --warn:#D9A54B;
|
|
|
|
|
+ --shadow:0 1px 2px rgba(0,0,0,.35),0 10px 28px rgba(0,0,0,.35);
|
|
|
|
|
+}
|
|
|
|
|
+*{box-sizing:border-box;margin:0;padding:0}
|
|
|
|
|
+html{color-scheme:light}
|
|
|
|
|
+[data-theme="dark"] html,[data-theme="dark"]{color-scheme:dark}
|
|
|
|
|
+body{
|
|
|
|
|
+ font-family:'Inter',system-ui,sans-serif;font-size:14.5px;line-height:1.5;
|
|
|
|
|
+ background:var(--bg);color:var(--ink);
|
|
|
|
|
+ transition:background .3s,color .3s;min-height:100vh;
|
|
|
|
|
+}
|
|
|
|
|
+h1,h2,h3{font-family:'Fraunces',serif;font-weight:700;letter-spacing:-.01em}
|
|
|
|
|
+code,.mono,textarea.mono,input.mono{font-family:'JetBrains Mono',monospace;font-size:12.5px}
|
|
|
|
|
+a{color:var(--gold)}
|
|
|
|
|
+button{font:inherit;cursor:pointer;border:none;background:none;color:inherit}
|
|
|
|
|
+
|
|
|
|
|
+.wrap{max-width:1100px;margin:0 auto;padding:0 20px 80px}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- header ---------- */
|
|
|
|
|
+header{display:flex;align-items:center;gap:14px;padding:26px 0 20px;flex-wrap:wrap}
|
|
|
|
|
+.logo{display:flex;align-items:center;gap:12px}
|
|
|
|
|
+.logo svg{display:block}
|
|
|
|
|
+.logo h1{font-size:26px}
|
|
|
|
|
+.logo .tag{font-size:12px;color:var(--ink-soft);margin-top:-2px}
|
|
|
|
|
+.chips{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto;align-items:center}
|
|
|
|
|
+.chip{font-size:11.5px;font-weight:600;padding:4px 10px;border-radius:999px;
|
|
|
|
|
+ border:1px solid var(--line);background:var(--bg-raise);color:var(--ink-soft)}
|
|
|
|
|
+.chip.hw{border-color:var(--gold);color:var(--gold);background:transparent}
|
|
|
|
|
+.theme-btn{width:38px;height:38px;border-radius:50%;border:1px solid var(--line);
|
|
|
|
|
+ background:var(--bg-raise);display:grid;place-items:center;transition:transform .2s}
|
|
|
|
|
+.theme-btn:hover{transform:rotate(20deg)}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- cards ---------- */
|
|
|
|
|
+.card{background:var(--bg-raise);border:1px solid var(--line);border-radius:var(--radius);
|
|
|
|
|
+ box-shadow:var(--shadow);padding:22px;margin-bottom:22px}
|
|
|
|
|
+.card h2{font-size:19px;margin-bottom:4px}
|
|
|
|
|
+.card .sub{color:var(--ink-soft);font-size:13px;margin-bottom:16px}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- dropzone ---------- */
|
|
|
|
|
+.drop{border:2px dashed var(--line);border-radius:var(--radius);padding:34px;
|
|
|
|
|
+ text-align:center;color:var(--ink-soft);transition:.2s;cursor:pointer;background:var(--bg-sunken)}
|
|
|
|
|
+.drop.hover{border-color:var(--gold);color:var(--gold);background:var(--gold-soft)}
|
|
|
|
|
+.drop strong{color:var(--ink)}
|
|
|
|
|
+.upbar{margin-top:14px;display:none}
|
|
|
|
|
+.upbar.show{display:block}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- progress (signature: gold pour) ---------- */
|
|
|
|
|
+.bar{height:10px;border-radius:999px;background:var(--bg-sunken);overflow:hidden;
|
|
|
|
|
+ border:1px solid var(--line);position:relative}
|
|
|
|
|
+.bar>i{display:block;height:100%;width:0;border-radius:999px;
|
|
|
|
|
+ background:linear-gradient(90deg,var(--gold) 0%,var(--gold-bright) 50%,var(--gold) 100%);
|
|
|
|
|
+ background-size:200% 100%;animation:pour 1.6s linear infinite;transition:width .4s ease}
|
|
|
|
|
+.bar.static>i{animation:none}
|
|
|
|
|
+@keyframes pour{from{background-position:0 0}to{background-position:-200% 0}}
|
|
|
|
|
+@media (prefers-reduced-motion:reduce){.bar>i{animation:none}}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- sources ---------- */
|
|
|
|
|
+.src-list{display:flex;flex-direction:column;gap:8px;margin-top:14px}
|
|
|
|
|
+.src{display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--line);
|
|
|
|
|
+ border-radius:10px;background:var(--bg-sunken);cursor:pointer;transition:.15s}
|
|
|
|
|
+.src:hover{border-color:var(--gold)}
|
|
|
|
|
+.src.sel{border-color:var(--gold);background:var(--gold-soft);box-shadow:inset 0 0 0 1px var(--gold)}
|
|
|
|
|
+.src .name{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
|
|
|
+.src .meta{margin-left:auto;color:var(--ink-soft);font-size:12px;white-space:nowrap}
|
|
|
|
|
+.src .del{color:var(--ink-soft);padding:2px 6px;border-radius:6px}
|
|
|
|
|
+.src .del:hover{color:var(--err)}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- tabs & fields ---------- */
|
|
|
|
|
+.tabs{display:flex;gap:2px;flex-wrap:wrap;border-bottom:1px solid var(--line);margin:6px 0 18px}
|
|
|
|
|
+.tab{padding:9px 14px;font-weight:600;font-size:13px;color:var(--ink-soft);
|
|
|
|
|
+ border-radius:8px 8px 0 0;border-bottom:2px solid transparent;margin-bottom:-1px}
|
|
|
|
|
+.tab.on{color:var(--gold);border-bottom-color:var(--gold)}
|
|
|
|
|
+.pane{display:none}.pane.on{display:block}
|
|
|
|
|
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:14px 16px}
|
|
|
|
|
+.field label{display:block;font-size:12px;font-weight:600;color:var(--ink-soft);
|
|
|
|
|
+ margin-bottom:5px;text-transform:uppercase;letter-spacing:.04em}
|
|
|
|
|
+.field input[type=text],.field input[type=number],.field select,.field textarea{
|
|
|
|
|
+ width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:8px;
|
|
|
|
|
+ background:var(--bg-sunken);color:var(--ink);font:inherit;font-size:13.5px}
|
|
|
|
|
+.field input:focus,.field select:focus,.field textarea:focus{
|
|
|
|
|
+ outline:2px solid var(--gold);outline-offset:1px;border-color:var(--gold)}
|
|
|
|
|
+.field.wide{grid-column:1/-1}
|
|
|
|
|
+.check{display:flex;align-items:center;gap:8px;font-size:13.5px;padding:6px 0}
|
|
|
|
|
+.check input{accent-color:var(--gold);width:16px;height:16px}
|
|
|
|
|
+.hint{font-size:11.5px;color:var(--ink-soft);margin-top:4px}
|
|
|
|
|
+textarea{resize:vertical;min-height:70px}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- buttons ---------- */
|
|
|
|
|
+.btn{display:inline-flex;align-items:center;gap:8px;padding:10px 18px;border-radius:10px;
|
|
|
|
|
+ font-weight:600;font-size:14px;border:1px solid var(--line);background:var(--bg-sunken);
|
|
|
|
|
+ color:var(--ink);transition:.15s}
|
|
|
|
|
+.btn:hover{border-color:var(--gold)}
|
|
|
|
|
+.btn.gold{background:var(--gold);border-color:var(--gold);color:#fff}
|
|
|
|
|
+[data-theme="dark"] .btn.gold{color:#221610}
|
|
|
|
|
+.btn.gold:hover{background:var(--gold-bright);border-color:var(--gold-bright)}
|
|
|
|
|
+.btn.sm{padding:6px 12px;font-size:12.5px;border-radius:8px}
|
|
|
|
|
+.btn.danger:hover{border-color:var(--err);color:var(--err)}
|
|
|
|
|
+.btn:disabled{opacity:.45;cursor:not-allowed}
|
|
|
|
|
+.actions{display:flex;gap:10px;align-items:center;margin-top:18px;flex-wrap:wrap}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------- jobs ---------- */
|
|
|
|
|
+.job{border:1px solid var(--line);border-radius:12px;padding:16px;margin-top:12px;background:var(--bg-sunken)}
|
|
|
|
|
+.job .row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
|
|
|
+.job .fname{font-weight:600}
|
|
|
|
|
+.status{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
|
|
|
|
|
+ padding:3px 9px;border-radius:999px;border:1px solid var(--line);color:var(--ink-soft)}
|
|
|
|
|
+.status.running,.status.scanning{color:var(--gold);border-color:var(--gold)}
|
|
|
|
|
+.status.done{color:var(--ok);border-color:var(--ok)}
|
|
|
|
|
+.status.failed{color:var(--err);border-color:var(--err)}
|
|
|
|
|
+.status.cancelled{color:var(--warn);border-color:var(--warn)}
|
|
|
|
|
+.job .stats{margin-left:auto;color:var(--ink-soft);font-size:12.5px;display:flex;gap:14px}
|
|
|
|
|
+.job .bar{margin-top:10px}
|
|
|
|
|
+.job .btns{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
|
|
|
|
|
+.job .errline{color:var(--err);font-size:12.5px;margin-top:8px}
|
|
|
|
|
+.logbox{display:none;margin-top:10px;background:var(--bg);border:1px solid var(--line);
|
|
|
|
|
+ border-radius:8px;padding:10px;max-height:260px;overflow:auto;white-space:pre-wrap;font-size:11.5px}
|
|
|
|
|
+.logbox.show{display:block}
|
|
|
|
|
+.empty{color:var(--ink-soft);text-align:center;padding:26px;font-size:13.5px}
|
|
|
|
|
+
|
|
|
|
|
+.toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%) translateY(20px);
|
|
|
|
|
+ background:var(--ink);color:var(--bg);padding:10px 20px;border-radius:10px;font-size:13.5px;
|
|
|
|
|
+ opacity:0;pointer-events:none;transition:.25s;z-index:50;box-shadow:var(--shadow)}
|
|
|
|
|
+.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
|
|
|
|
+footer{color:var(--ink-soft);font-size:12px;text-align:center;padding-top:8px}
|
|
|
|
|
+@media(max-width:640px){.job .stats{margin-left:0;width:100%}}
|
|
|
|
|
+</style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+<div class="wrap">
|
|
|
|
|
+
|
|
|
|
|
+<header>
|
|
|
|
|
+ <div class="logo">
|
|
|
|
|
+ <!-- coffee-ring / reel mark -->
|
|
|
|
|
+ <svg width="42" height="42" viewBox="0 0 42 42" fill="none" aria-hidden="true">
|
|
|
|
|
+ <circle cx="21" cy="21" r="17" stroke="var(--gold)" stroke-width="3.5"/>
|
|
|
|
|
+ <circle cx="21" cy="21" r="9" stroke="var(--gold)" stroke-width="2" stroke-dasharray="4 5"/>
|
|
|
|
|
+ <circle cx="21" cy="21" r="3" fill="var(--gold)"/>
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <h1>WeBrake</h1>
|
|
|
|
|
+ <div class="tag">HandBrake, poured over the web</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="chips" id="chips">
|
|
|
|
|
+ <span class="chip" id="chip-hb">HandBrake …</span>
|
|
|
|
|
+ <button class="theme-btn" id="themeBtn" title="Toggle dark roast" aria-label="Toggle theme">
|
|
|
|
|
+ <svg id="themeIcon" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
|
|
|
|
+ stroke="currentColor" stroke-width="2" stroke-linecap="round"></svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</header>
|
|
|
|
|
+
|
|
|
|
|
+<!-- ============ upload ============ -->
|
|
|
|
|
+<section class="card">
|
|
|
|
|
+ <h2>1 · Pour in your media</h2>
|
|
|
|
|
+ <div class="sub">Drop a video file to upload it to the container for remuxing or re-encoding.</div>
|
|
|
|
|
+ <div class="drop" id="drop" tabindex="0" role="button" aria-label="Upload a video file">
|
|
|
|
|
+ <strong>Drop a file here</strong> or click to browse
|
|
|
|
|
+ <div class="hint">MKV, MP4, AVI, TS, MOV, WebM — anything HandBrake can scan.</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <input type="file" id="fileInput" hidden>
|
|
|
|
|
+ <div class="upbar" id="upbar">
|
|
|
|
|
+ <div class="bar"><i id="upfill"></i></div>
|
|
|
|
|
+ <div class="hint" id="uptext">Uploading…</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="src-list" id="srcList"></div>
|
|
|
|
|
+</section>
|
|
|
|
|
+
|
|
|
|
|
+<!-- ============ options ============ -->
|
|
|
|
|
+<section class="card">
|
|
|
|
|
+ <h2>2 · Dial in the brew</h2>
|
|
|
|
|
+ <div class="sub">Every HandBrake feature is here — pick a preset, fine-tune the groups, or drop raw flags in Advanced.</div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="grid" style="margin-bottom:4px">
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label for="preset">Preset</label>
|
|
|
|
|
+ <select id="preset"><option value="">(none — manual settings)</option></select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label for="o_format">Container</label>
|
|
|
|
|
+ <select id="o_format">
|
|
|
|
|
+ <option value="av_mkv">MKV (av_mkv)</option>
|
|
|
|
|
+ <option value="av_mp4">MP4 (av_mp4)</option>
|
|
|
|
|
+ <option value="av_webm">WebM (av_webm)</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label for="o_output_name">Output name (no extension)</label>
|
|
|
|
|
+ <input type="text" id="o_output_name" placeholder="defaults to source name">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="tabs" id="tabs">
|
|
|
|
|
+ <button class="tab on" data-pane="p-src">Source</button>
|
|
|
|
|
+ <button class="tab" data-pane="p-vid">Video</button>
|
|
|
|
|
+ <button class="tab" data-pane="p-aud">Audio</button>
|
|
|
|
|
+ <button class="tab" data-pane="p-sub">Subtitles</button>
|
|
|
|
|
+ <button class="tab" data-pane="p-pic">Picture</button>
|
|
|
|
|
+ <button class="tab" data-pane="p-fil">Filters</button>
|
|
|
|
|
+ <button class="tab" data-pane="p-adv">Advanced</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Source -->
|
|
|
|
|
+ <div class="pane on" id="p-src"><div class="grid">
|
|
|
|
|
+ <div class="field"><label>Title #</label><input type="number" id="o_title" min="0" placeholder="auto"></div>
|
|
|
|
|
+ <div class="field"><label>Chapters</label><input type="text" id="o_chapters" placeholder="e.g. 1-3"></div>
|
|
|
|
|
+ <div class="field"><label>Angle</label><input type="number" id="o_angle" min="1" placeholder=""></div>
|
|
|
|
|
+ <div class="field"><label>Min duration (s)</label><input type="number" id="o_min_duration" placeholder=""></div>
|
|
|
|
|
+ <div class="field"><label>Start at</label><input type="text" id="o_start_at" placeholder="seconds:30 / frames:100 / duration:00:01:00"></div>
|
|
|
|
|
+ <div class="field"><label>Stop at</label><input type="text" id="o_stop_at" placeholder="same units as start"></div>
|
|
|
|
|
+ <div class="field"><label>Previews</label><input type="text" id="o_previews" placeholder="e.g. 30:1"></div>
|
|
|
|
|
+ <div class="field"><label>Start at preview #</label><input type="number" id="o_start_at_preview" placeholder=""></div>
|
|
|
|
|
+ <div class="field wide">
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_main_feature"> Main feature title only (--main-feature)</label>
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_markers" checked> Chapter markers (--markers)</label>
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_optimize"> Web optimized / fast start (--optimize)</label>
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_align_av"> Align A/V start (--align-av)</label>
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_inline_parameter_sets"> Inline parameter sets (--inline-parameter-sets)</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="field wide" id="scanBox">
|
|
|
|
|
+ <button class="btn sm" id="scanBtn" type="button">Scan source for titles & tracks</button>
|
|
|
|
|
+ <div class="hint mono" id="scanOut"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div></div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Video -->
|
|
|
|
|
+ <div class="pane" id="p-vid"><div class="grid">
|
|
|
|
|
+ <div class="field"><label>Encoder</label><select id="o_encoder"><option value="">(preset default)</option></select>
|
|
|
|
|
+ <div class="hint" id="hwHint"></div></div>
|
|
|
|
|
+ <div class="field"><label>Encoder preset</label><input type="text" id="o_encoder_preset" placeholder="e.g. medium / p5 / quality"></div>
|
|
|
|
|
+ <div class="field"><label>Encoder tune</label><input type="text" id="o_encoder_tune" placeholder="e.g. film, animation"></div>
|
|
|
|
|
+ <div class="field"><label>Profile</label><input type="text" id="o_encoder_profile" placeholder="e.g. main, high, main10"></div>
|
|
|
|
|
+ <div class="field"><label>Level</label><input type="text" id="o_encoder_level" placeholder="e.g. 4.1"></div>
|
|
|
|
|
+ <div class="field"><label>Quality (CQ/RF)</label><input type="number" id="o_quality" step="0.25" placeholder="e.g. 22"></div>
|
|
|
|
|
+ <div class="field"><label>Bitrate kb/s (--vb)</label><input type="number" id="o_vb" placeholder="overrides quality"></div>
|
|
|
|
|
+ <div class="field"><label>Framerate (--rate)</label><input type="text" id="o_rate" placeholder="e.g. 23.976, 30"></div>
|
|
|
|
|
+ <div class="field"><label>Framerate mode</label>
|
|
|
|
|
+ <select id="o_fr_mode"><option value="">source default</option>
|
|
|
|
|
+ <option value="cfr">Constant (--cfr)</option>
|
|
|
|
|
+ <option value="vfr">Variable (--vfr)</option>
|
|
|
|
|
+ <option value="pfr">Peak (--pfr)</option></select></div>
|
|
|
|
|
+ <div class="field wide"><label>Extra encoder options (--encopts)</label>
|
|
|
|
|
+ <input type="text" class="mono" id="o_encopts" placeholder="key=value:key=value"></div>
|
|
|
|
|
+ <div class="field wide">
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_two_pass"> Two-pass encode (--two-pass)</label>
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_turbo"> Turbo first pass (--turbo)</label>
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_hwdec"> Hardware decoding (--enable-hw-decoding)</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div></div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Audio -->
|
|
|
|
|
+ <div class="pane" id="p-aud"><div class="grid">
|
|
|
|
|
+ <div class="field"><label>Tracks (--audio)</label><input type="text" id="o_audio" placeholder="e.g. 1,2 or leave blank"></div>
|
|
|
|
|
+ <div class="field"><label>Language list</label><input type="text" id="o_audio_lang_list" placeholder="e.g. eng,jpn"></div>
|
|
|
|
|
+ <div class="field"><label>Encoder(s) (--aencoder)</label><input type="text" id="o_aencoder" placeholder="e.g. copy, av_aac, opus, flac"></div>
|
|
|
|
|
+ <div class="field"><label>Copy mask</label><input type="text" id="o_audio_copy_mask" placeholder="e.g. aac,ac3,dtshd,truehd"></div>
|
|
|
|
|
+ <div class="field"><label>Fallback encoder</label><input type="text" id="o_audio_fallback" placeholder="e.g. av_aac"></div>
|
|
|
|
|
+ <div class="field"><label>Bitrate(s) kb/s (--ab)</label><input type="text" id="o_ab" placeholder="e.g. 160,256"></div>
|
|
|
|
|
+ <div class="field"><label>Quality(ies) (--aq)</label><input type="text" id="o_aq" placeholder=""></div>
|
|
|
|
|
+ <div class="field"><label>Compression (--ac)</label><input type="text" id="o_ac" placeholder=""></div>
|
|
|
|
|
+ <div class="field"><label>Mixdown</label><input type="text" id="o_mixdown" placeholder="e.g. stereo, 5point1"></div>
|
|
|
|
|
+ <div class="field"><label>Normalize mix</label><input type="text" id="o_normalize_mix" placeholder="0 or 1"></div>
|
|
|
|
|
+ <div class="field"><label>Samplerate kHz (--arate)</label><input type="text" id="o_arate" placeholder="e.g. 48"></div>
|
|
|
|
|
+ <div class="field"><label>DRC (--drc)</label><input type="text" id="o_drc" placeholder="1.0–4.0"></div>
|
|
|
|
|
+ <div class="field"><label>Gain dB</label><input type="text" id="o_gain" placeholder="e.g. 2"></div>
|
|
|
|
|
+ <div class="field"><label>Dither (--adither)</label><input type="text" id="o_adither" placeholder="auto / none / …"></div>
|
|
|
|
|
+ <div class="field wide"><label>Track name(s) (--aname)</label><input type="text" id="o_aname" placeholder="comma-separated"></div>
|
|
|
|
|
+ <div class="field wide">
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_all_audio"> Include all audio tracks (--all-audio)</label>
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_first_audio"> First audio track only (--first-audio)</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div></div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Subtitles -->
|
|
|
|
|
+ <div class="pane" id="p-sub"><div class="grid">
|
|
|
|
|
+ <div class="field"><label>Tracks (--subtitle)</label><input type="text" id="o_subtitle" placeholder="e.g. 1,2 / scan"></div>
|
|
|
|
|
+ <div class="field"><label>Language list</label><input type="text" id="o_subtitle_lang_list" placeholder="e.g. eng"></div>
|
|
|
|
|
+ <div class="field"><label>Forced only (--subtitle-forced)</label><input type="text" id="o_subtitle_forced" placeholder="track #s or blank"></div>
|
|
|
|
|
+ <div class="field"><label>Burn in (--subtitle-burned)</label><input type="text" id="o_subtitle_burned" placeholder="track # / native / none"></div>
|
|
|
|
|
+ <div class="field"><label>Default track</label><input type="text" id="o_subtitle_default" placeholder="track # / none"></div>
|
|
|
|
|
+ <div class="field"><label>Track name(s) (--subname)</label><input type="text" id="o_subname" placeholder=""></div>
|
|
|
|
|
+ <div class="field"><label>Native language</label><input type="text" id="o_native_language" placeholder="e.g. eng"></div>
|
|
|
|
|
+ <div class="field wide"><label>External SRT file path (--srt-file)</label>
|
|
|
|
|
+ <input type="text" class="mono" id="o_srt_file" placeholder="/var/lib/webrake/uploads/…"></div>
|
|
|
|
|
+ <div class="field"><label>SRT codeset</label><input type="text" id="o_srt_codeset" placeholder="UTF-8"></div>
|
|
|
|
|
+ <div class="field"><label>SRT offset ms</label><input type="text" id="o_srt_offset" placeholder=""></div>
|
|
|
|
|
+ <div class="field"><label>SRT language</label><input type="text" id="o_srt_lang" placeholder="e.g. eng"></div>
|
|
|
|
|
+ <div class="field"><label>SRT burn (track #)</label><input type="text" id="o_srt_burn" placeholder=""></div>
|
|
|
|
|
+ <div class="field wide">
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_all_subtitles"> Include all subtitles (--all-subtitles)</label>
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_first_subtitle"> First subtitle only (--first-subtitle)</label>
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_native_dub"> Prefer native-language dub (--native-dub)</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div></div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Picture -->
|
|
|
|
|
+ <div class="pane" id="p-pic"><div class="grid">
|
|
|
|
|
+ <div class="field"><label>Width</label><input type="number" id="o_width" placeholder=""></div>
|
|
|
|
|
+ <div class="field"><label>Height</label><input type="number" id="o_height" placeholder=""></div>
|
|
|
|
|
+ <div class="field"><label>Max width</label><input type="number" id="o_maxWidth" placeholder=""></div>
|
|
|
|
|
+ <div class="field"><label>Max height</label><input type="number" id="o_maxHeight" placeholder=""></div>
|
|
|
|
|
+ <div class="field"><label>Crop T:B:L:R</label><input type="text" id="o_crop" placeholder="e.g. 0:0:0:0"></div>
|
|
|
|
|
+ <div class="field"><label>Crop mode</label>
|
|
|
|
|
+ <select id="o_crop_mode"><option value="">default</option>
|
|
|
|
|
+ <option>auto</option><option>conservative</option><option>none</option></select></div>
|
|
|
|
|
+ <div class="field"><label>Anamorphic</label>
|
|
|
|
|
+ <select id="o_anamorphic"><option value="">default</option>
|
|
|
|
|
+ <option value="auto_anamorphic">auto</option>
|
|
|
|
|
+ <option value="loose_anamorphic">loose</option>
|
|
|
|
|
+ <option value="custom_anamorphic">custom</option>
|
|
|
|
|
+ <option value="non_anamorphic">none</option></select></div>
|
|
|
|
|
+ <div class="field"><label>Display width</label><input type="number" id="o_display_width" placeholder=""></div>
|
|
|
|
|
+ <div class="field"><label>Pixel aspect X:Y</label><input type="text" id="o_pixel_aspect" placeholder="e.g. 1:1"></div>
|
|
|
|
|
+ <div class="field"><label>Modulus</label><input type="number" id="o_modulus" placeholder="2"></div>
|
|
|
|
|
+ <div class="field"><label>Color matrix</label><input type="text" id="o_color_matrix" placeholder="709 / 601 / 2020 / …"></div>
|
|
|
|
|
+ <div class="field wide">
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_keep_display_aspect"> Keep display aspect (--keep-display-aspect)</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div></div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Filters -->
|
|
|
|
|
+ <div class="pane" id="p-fil"><div class="grid">
|
|
|
|
|
+ <div class="field"><label>Comb detect</label><input type="text" id="o_comb_detect" placeholder="blank=off · 'default' or settings"></div>
|
|
|
|
|
+ <div class="field"><label>Deinterlace</label><input type="text" id="o_deinterlace" placeholder="'default' or preset/settings"></div>
|
|
|
|
|
+ <div class="field"><label>Decomb</label><input type="text" id="o_decomb" placeholder="'default' or settings"></div>
|
|
|
|
|
+ <div class="field"><label>Detelecine</label><input type="text" id="o_detelecine" placeholder="'default' or settings"></div>
|
|
|
|
|
+ <div class="field"><label>Denoise hqdn3d</label><input type="text" id="o_hqdn3d" placeholder="light / medium / strong / custom"></div>
|
|
|
|
|
+ <div class="field"><label>Denoise NLMeans</label><input type="text" id="o_nlmeans" placeholder="ultralight … strong"></div>
|
|
|
|
|
+ <div class="field"><label>NLMeans tune</label><input type="text" id="o_nlmeans_tune" placeholder="film / grain / …"></div>
|
|
|
|
|
+ <div class="field"><label>Chroma smooth</label><input type="text" id="o_chroma_smooth" placeholder=""></div>
|
|
|
|
|
+ <div class="field"><label>Unsharp</label><input type="text" id="o_unsharp" placeholder="light / medium / strong"></div>
|
|
|
|
|
+ <div class="field"><label>Lapsharp</label><input type="text" id="o_lapsharp" placeholder=""></div>
|
|
|
|
|
+ <div class="field"><label>Deblock</label><input type="text" id="o_deblock" placeholder=""></div>
|
|
|
|
|
+ <div class="field"><label>Rotate</label><input type="text" id="o_rotate" placeholder="angle=90:hflip=0"></div>
|
|
|
|
|
+ <div class="field"><label>Pad</label><input type="text" id="o_pad" placeholder="W:H:color:X:Y"></div>
|
|
|
|
|
+ <div class="field"><label>Colorspace</label><input type="text" id="o_colorspace" placeholder="bt709 / bt2020 / …"></div>
|
|
|
|
|
+ <div class="field wide">
|
|
|
|
|
+ <label class="check"><input type="checkbox" id="o_grayscale"> Grayscale (--grayscale)</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <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>
|
|
|
|
|
+ </div></div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Advanced -->
|
|
|
|
|
+ <div class="pane" id="p-adv"><div class="grid">
|
|
|
|
|
+ <div class="field wide"><label>Raw HandBrakeCLI flags — everything else lives here</label>
|
|
|
|
|
+ <textarea id="o_extra_args" class="mono" placeholder="--preset-import-file /path/custom.json --subtitle-lang-list eng --queue-import-file …"></textarea>
|
|
|
|
|
+ <div class="hint">Appended verbatim to the command line, so the entire HandBrake flag surface is available. See <code>HandBrakeCLI --help</code>.</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="field wide"><label>Command preview</label>
|
|
|
|
|
+ <textarea id="cmdPreview" class="mono" readonly rows="3"></textarea></div>
|
|
|
|
|
+ </div></div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="actions">
|
|
|
|
|
+ <button class="btn gold" id="startBtn">Start encode</button>
|
|
|
|
|
+ <span class="hint" id="startHint">Select a source above first.</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</section>
|
|
|
|
|
+
|
|
|
|
|
+<!-- ============ queue ============ -->
|
|
|
|
|
+<section class="card">
|
|
|
|
|
+ <h2>3 · The counter</h2>
|
|
|
|
|
+ <div class="sub">Live queue — watch progress, then download the finished cup back to this PC.</div>
|
|
|
|
|
+ <div id="jobs"><div class="empty">Nothing brewing yet.</div></div>
|
|
|
|
|
+</section>
|
|
|
|
|
+
|
|
|
|
|
+<footer>WeBrake · <span id="verline"></span> · <a href="https://gogs.av2x.dev/av2x/WeBrake">source</a></footer>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<div class="toast" id="toast"></div>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+"use strict";
|
|
|
|
|
+/* ---------------- helpers ---------------- */
|
|
|
|
|
+const $ = s => document.querySelector(s);
|
|
|
|
|
+const $$ = s => [...document.querySelectorAll(s)];
|
|
|
|
|
+let API_TOKEN = localStorage.getItem("webrake_token") || "";
|
|
|
|
|
+function headers(extra){ const h = extra||{}; if(API_TOKEN) h["X-API-Token"]=API_TOKEN; return h; }
|
|
|
|
|
+async function api(path, opts={}){
|
|
|
|
|
+ opts.headers = headers(opts.headers);
|
|
|
|
|
+ const r = await fetch(path, opts);
|
|
|
|
|
+ if(r.status===401){
|
|
|
|
|
+ API_TOKEN = prompt("This WeBrake requires an API token (see token.json in /opt/webrake):")||"";
|
|
|
|
|
+ localStorage.setItem("webrake_token", API_TOKEN);
|
|
|
|
|
+ if(API_TOKEN) return api(path, opts);
|
|
|
|
|
+ }
|
|
|
|
|
+ if(!r.ok){ let e={}; try{e=await r.json()}catch{} throw new Error(e.error||r.statusText); }
|
|
|
|
|
+ return r;
|
|
|
|
|
+}
|
|
|
|
|
+function toast(msg){ const t=$("#toast"); t.textContent=msg; t.classList.add("show");
|
|
|
|
|
+ clearTimeout(t._h); t._h=setTimeout(()=>t.classList.remove("show"),2600); }
|
|
|
|
|
+function fmtSize(b){ if(b>1e9)return (b/1e9).toFixed(2)+" GB"; if(b>1e6)return (b/1e6).toFixed(1)+" MB";
|
|
|
|
|
+ return (b/1e3).toFixed(0)+" KB"; }
|
|
|
|
|
+function fmtEta(s){ if(!s)return ""; const h=~~(s/3600),m=~~(s%3600/60),ss=~~(s%60);
|
|
|
|
|
+ return (h?h+"h ":"")+(m?m+"m ":"")+ss+"s"; }
|
|
|
|
|
+function esc(x){ const d=document.createElement("div"); d.textContent=x??""; return d.innerHTML; }
|
|
|
|
|
+
|
|
|
|
|
+/* ---------------- theme ---------------- */
|
|
|
|
|
+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"/>';
|
|
|
|
|
+const moonPath = '<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>';
|
|
|
|
|
+function setTheme(t){
|
|
|
|
|
+ document.documentElement.dataset.theme=t;
|
|
|
|
|
+ localStorage.setItem("webrake_theme",t);
|
|
|
|
|
+ $("#themeIcon").innerHTML = t==="dark" ? sunPath : moonPath;
|
|
|
|
|
+}
|
|
|
|
|
+setTheme(localStorage.getItem("webrake_theme") ||
|
|
|
|
|
+ (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"));
|
|
|
|
|
+$("#themeBtn").onclick = () =>
|
|
|
|
|
+ setTheme(document.documentElement.dataset.theme==="dark" ? "light" : "dark");
|
|
|
|
|
+
|
|
|
|
|
+/* ---------------- tabs ---------------- */
|
|
|
|
|
+$$("#tabs .tab").forEach(t => t.onclick = () => {
|
|
|
|
|
+ $$("#tabs .tab").forEach(x=>x.classList.remove("on"));
|
|
|
|
|
+ $$(".pane").forEach(x=>x.classList.remove("on"));
|
|
|
|
|
+ t.classList.add("on"); $("#"+t.dataset.pane).classList.add("on");
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+/* ---------------- capabilities / presets ---------------- */
|
|
|
|
|
+let CAPS = {};
|
|
|
|
|
+async function loadMeta(){
|
|
|
|
|
+ try{
|
|
|
|
|
+ const v = await (await api("/api/version")).json();
|
|
|
|
|
+ $("#verline").textContent = "v"+v.webrake+" · HandBrake "+(v.handbrake||"not found");
|
|
|
|
|
+ CAPS = await (await api("/api/capabilities")).json();
|
|
|
|
|
+ $("#chip-hb").textContent = CAPS.handbrake_found
|
|
|
|
|
+ ? "HandBrake "+(CAPS.handbrake_version||"") : "HandBrake missing!";
|
|
|
|
|
+ const chips = $("#chips"), btn = $("#themeBtn");
|
|
|
|
|
+ for(const [k,label] of [["qsv","Intel QSV"],["nvenc","NVENC"],["vce","AMD VCE"],["vaapi","VAAPI"]]){
|
|
|
|
|
+ if(CAPS[k]){ const c=document.createElement("span"); c.className="chip hw";
|
|
|
|
|
+ c.textContent="⚡ "+label; chips.insertBefore(c,btn); }
|
|
|
|
|
+ }
|
|
|
|
|
+ const sel = $("#o_encoder");
|
|
|
|
|
+ (CAPS.encoders||[]).forEach(e=>{
|
|
|
|
|
+ const o=document.createElement("option"); o.value=e;
|
|
|
|
|
+ o.textContent=(CAPS.hw_encoders||[]).includes(e) ? "⚡ "+e+" (GPU)" : e;
|
|
|
|
|
+ sel.appendChild(o);
|
|
|
|
|
+ });
|
|
|
|
|
+ const hw=(CAPS.hw_encoders||[]);
|
|
|
|
|
+ $("#hwHint").textContent = hw.length
|
|
|
|
|
+ ? "GPU passthru detected — ⚡ encoders run on hardware."
|
|
|
|
|
+ : "No hardware encoders detected; software encoders only.";
|
|
|
|
|
+ const p = await (await api("/api/presets")).json();
|
|
|
|
|
+ let lastCat=null, group=null, ps=$("#preset");
|
|
|
|
|
+ (p.presets||[]).forEach(pr=>{
|
|
|
|
|
+ if(pr.category!==lastCat){ group=document.createElement("optgroup");
|
|
|
|
|
+ group.label=pr.category; ps.appendChild(group); lastCat=pr.category; }
|
|
|
|
|
+ const o=document.createElement("option"); o.value=pr.name; o.textContent=pr.name;
|
|
|
|
|
+ group.appendChild(o);
|
|
|
|
|
+ });
|
|
|
|
|
+ }catch(e){ toast("Backend unreachable: "+e.message); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------------- uploads ---------------- */
|
|
|
|
|
+let SELECTED = null;
|
|
|
|
|
+const drop=$("#drop"), fi=$("#fileInput");
|
|
|
|
|
+drop.onclick=()=>fi.click();
|
|
|
|
|
+drop.onkeydown=e=>{ if(e.key==="Enter"||e.key===" ") fi.click(); };
|
|
|
|
|
+["dragover","dragenter"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add("hover");}));
|
|
|
|
|
+["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove("hover");}));
|
|
|
|
|
+drop.addEventListener("drop",e=>{ if(e.dataTransfer.files.length) upload(e.dataTransfer.files[0]); });
|
|
|
|
|
+fi.onchange=()=>{ if(fi.files.length) upload(fi.files[0]); fi.value=""; };
|
|
|
|
|
+
|
|
|
|
|
+function upload(file){
|
|
|
|
|
+ const xhr=new XMLHttpRequest(), fd=new FormData(); fd.append("file",file);
|
|
|
|
|
+ $("#upbar").classList.add("show");
|
|
|
|
|
+ const fill=$("#upfill"), txt=$("#uptext");
|
|
|
|
|
+ xhr.upload.onprogress=e=>{
|
|
|
|
|
+ if(e.lengthComputable){
|
|
|
|
|
+ const pct=(e.loaded/e.total*100);
|
|
|
|
|
+ fill.style.width=pct.toFixed(1)+"%";
|
|
|
|
|
+ txt.textContent=`Uploading ${file.name} — ${pct.toFixed(1)}% of ${fmtSize(e.total)}`;
|
|
|
|
|
+ }};
|
|
|
|
|
+ xhr.onload=()=>{
|
|
|
|
|
+ $("#upbar").classList.remove("show"); fill.style.width="0";
|
|
|
|
|
+ if(xhr.status===200){ toast("Uploaded "+file.name); refreshUploads(); }
|
|
|
|
|
+ else toast("Upload failed ("+xhr.status+")");
|
|
|
|
|
+ };
|
|
|
|
|
+ xhr.onerror=()=>{ $("#upbar").classList.remove("show"); toast("Upload failed"); };
|
|
|
|
|
+ xhr.open("POST","/api/upload");
|
|
|
|
|
+ if(API_TOKEN) xhr.setRequestHeader("X-API-Token",API_TOKEN);
|
|
|
|
|
+ xhr.send(fd);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function refreshUploads(){
|
|
|
|
|
+ try{
|
|
|
|
|
+ const d = await (await api("/api/uploads")).json();
|
|
|
|
|
+ const list=$("#srcList"); list.innerHTML="";
|
|
|
|
|
+ d.uploads.forEach(u=>{
|
|
|
|
|
+ const row=document.createElement("div");
|
|
|
|
|
+ row.className="src"+(SELECTED===u.upload_id?" sel":"");
|
|
|
|
|
+ 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>
|
|
|
|
|
+ <span class="name">${esc(u.filename)}</span>
|
|
|
|
|
+ <span class="meta">${fmtSize(u.size)}</span>
|
|
|
|
|
+ <button class="del" title="Delete upload" aria-label="Delete upload">✕</button>`;
|
|
|
|
|
+ row.onclick=e=>{
|
|
|
|
|
+ if(e.target.classList.contains("del")) return;
|
|
|
|
|
+ SELECTED=u.upload_id; refreshUploads(); updateStartState(); previewCmd();
|
|
|
|
|
+ };
|
|
|
|
|
+ row.querySelector(".del").onclick=async ()=>{
|
|
|
|
|
+ await api("/api/uploads/"+encodeURIComponent(u.upload_id),{method:"DELETE"});
|
|
|
|
|
+ if(SELECTED===u.upload_id){SELECTED=null;updateStartState();}
|
|
|
|
|
+ refreshUploads(); toast("Upload deleted");
|
|
|
|
|
+ };
|
|
|
|
|
+ list.appendChild(row);
|
|
|
|
|
+ });
|
|
|
|
|
+ if(!d.uploads.length) list.innerHTML='<div class="empty">No sources uploaded yet.</div>';
|
|
|
|
|
+ }catch(e){/* backend down; header toast already covers it */}
|
|
|
|
|
+}
|
|
|
|
|
+function updateStartState(){
|
|
|
|
|
+ $("#startBtn").disabled=!SELECTED;
|
|
|
|
|
+ $("#startHint").textContent=SELECTED?"":"Select a source above first.";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------------- scan ---------------- */
|
|
|
|
|
+$("#scanBtn").onclick=async ()=>{
|
|
|
|
|
+ if(!SELECTED){ toast("Select a source first"); return; }
|
|
|
|
|
+ $("#scanOut").textContent="Scanning…";
|
|
|
|
|
+ try{
|
|
|
|
|
+ const d = await (await api("/api/scan/"+encodeURIComponent(SELECTED))).json();
|
|
|
|
|
+ const lines=(d.TitleList||[]).map(t=>
|
|
|
|
|
+ `Title ${t.Index}: ${t.Duration?`${t.Duration.Hours}:${String(t.Duration.Minutes).padStart(2,"0")}:${String(t.Duration.Seconds).padStart(2,"0")}`:""} · `+
|
|
|
|
|
+ `${t.Geometry?t.Geometry.Width+"×"+t.Geometry.Height:""} · `+
|
|
|
|
|
+ `${(t.AudioList||[]).length} audio · ${(t.SubtitleList||[]).length} subs`);
|
|
|
|
|
+ $("#scanOut").textContent=lines.join("\n")||"No titles found.";
|
|
|
|
|
+ }catch(e){ $("#scanOut").textContent="Scan failed: "+e.message; }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/* ---------------- options collection ---------------- */
|
|
|
|
|
+const TEXT_KEYS=["title","chapters","angle","min_duration","start_at","stop_at","previews",
|
|
|
|
|
+ "start_at_preview","encoder","encoder_preset","encoder_tune","encoder_profile","encoder_level",
|
|
|
|
|
+ "quality","vb","rate","encopts","audio","audio_lang_list","aencoder","audio_copy_mask",
|
|
|
|
|
+ "audio_fallback","ab","aq","ac","mixdown","normalize_mix","arate","drc","gain","adither","aname",
|
|
|
|
|
+ "subtitle","subtitle_lang_list","subtitle_forced","subtitle_burned","subtitle_default","subname",
|
|
|
|
|
+ "native_language","srt_file","srt_codeset","srt_offset","srt_lang","srt_burn",
|
|
|
|
|
+ "width","height","maxWidth","maxHeight","crop","crop_mode","display_width","pixel_aspect",
|
|
|
|
|
+ "modulus","color_matrix","comb_detect","deinterlace","decomb","detelecine","hqdn3d","nlmeans",
|
|
|
|
|
+ "nlmeans_tune","chroma_smooth","unsharp","lapsharp","deblock","rotate","pad","colorspace",
|
|
|
|
|
+ "output_name","extra_args","preset_import_file"];
|
|
|
|
|
+const BOOL_KEYS=["main_feature","markers","optimize","align_av","inline_parameter_sets",
|
|
|
|
|
+ "two_pass","turbo","all_audio","first_audio","all_subtitles","first_subtitle","native_dub",
|
|
|
|
|
+ "keep_display_aspect","grayscale"];
|
|
|
|
|
+const FILTER_KEYS=new Set(["comb_detect","deinterlace","decomb","detelecine","hqdn3d","nlmeans",
|
|
|
|
|
+ "chroma_smooth","unsharp","lapsharp","deblock","rotate","subtitle_forced","subtitle_burned",
|
|
|
|
|
+ "subtitle_default","srt_burn"]);
|
|
|
|
|
+
|
|
|
|
|
+function collectOptions(){
|
|
|
|
|
+ const o={};
|
|
|
|
|
+ const preset=$("#preset").value; if(preset) o.preset=preset;
|
|
|
|
|
+ o.format=$("#o_format").value;
|
|
|
|
|
+ for(const k of TEXT_KEYS){
|
|
|
|
|
+ const el=document.getElementById("o_"+k); if(!el) continue;
|
|
|
|
|
+ let v=el.value.trim(); if(v==="") continue;
|
|
|
|
|
+ if(FILTER_KEYS.has(k) && v.toLowerCase()==="default") v=true;
|
|
|
|
|
+ o[k]=v;
|
|
|
|
|
+ }
|
|
|
|
|
+ for(const k of BOOL_KEYS){
|
|
|
|
|
+ const el=document.getElementById("o_"+k);
|
|
|
|
|
+ if(el && el.checked) o[k]=true;
|
|
|
|
|
+ }
|
|
|
|
|
+ const fr=$("#o_fr_mode").value; if(fr) o[fr]=true;
|
|
|
|
|
+ const an=$("#o_anamorphic").value; if(an) o[an]=true;
|
|
|
|
|
+ if($("#o_hwdec").checked) o.enable_hw_decoding =
|
|
|
|
|
+ CAPS.nvenc ? "nvdec" : (CAPS.qsv ? "qsv" : "videotoolbox");
|
|
|
|
|
+ return o;
|
|
|
|
|
+}
|
|
|
|
|
+function previewCmd(){
|
|
|
|
|
+ const o=collectOptions(), parts=["HandBrakeCLI --json -i <source> -o <output>"];
|
|
|
|
|
+ for(const [k,v] of Object.entries(o)){
|
|
|
|
|
+ if(k==="output_name") continue;
|
|
|
|
|
+ const flag="--"+k.replace(/_/g,"-");
|
|
|
|
|
+ if(v===true) parts.push(flag);
|
|
|
|
|
+ else if(k==="extra_args") parts.push(v);
|
|
|
|
|
+ else parts.push(flag+" "+v);
|
|
|
|
|
+ }
|
|
|
|
|
+ $("#cmdPreview").value=parts.join(" ");
|
|
|
|
|
+}
|
|
|
|
|
+document.addEventListener("input",previewCmd);
|
|
|
|
|
+document.addEventListener("change",previewCmd);
|
|
|
|
|
+
|
|
|
|
|
+/* ---------------- jobs ---------------- */
|
|
|
|
|
+$("#startBtn").onclick=async ()=>{
|
|
|
|
|
+ if(!SELECTED) return;
|
|
|
|
|
+ try{
|
|
|
|
|
+ await api("/api/jobs",{method:"POST",
|
|
|
|
|
+ headers:{"Content-Type":"application/json"},
|
|
|
|
|
+ body:JSON.stringify({upload_id:SELECTED,options:collectOptions()})});
|
|
|
|
|
+ toast("Job queued"); refreshJobs();
|
|
|
|
|
+ }catch(e){ toast("Could not queue job: "+e.message); }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const openLogs=new Set();
|
|
|
|
|
+async function refreshJobs(){
|
|
|
|
|
+ try{
|
|
|
|
|
+ const d=await (await api("/api/jobs")).json();
|
|
|
|
|
+ const box=$("#jobs");
|
|
|
|
|
+ if(!d.jobs.length){ box.innerHTML='<div class="empty">Nothing brewing yet.</div>'; return; }
|
|
|
|
|
+ box.innerHTML="";
|
|
|
|
|
+ d.jobs.forEach(j=>{
|
|
|
|
|
+ const el=document.createElement("div"); el.className="job"; el.dataset.id=j.id;
|
|
|
|
|
+ const running=["running","scanning","queued"].includes(j.status);
|
|
|
|
|
+ const stats=[];
|
|
|
|
|
+ if(j.fps) stats.push(j.fps+" fps");
|
|
|
|
|
+ if(j.eta) stats.push("ETA "+fmtEta(j.eta));
|
|
|
|
|
+ if(j.pass&&j.pass>0) stats.push("pass "+j.pass);
|
|
|
|
|
+ el.innerHTML=`
|
|
|
|
|
+ <div class="row">
|
|
|
|
|
+ <span class="fname">${esc(j.filename)}</span>
|
|
|
|
|
+ <span class="status ${j.status}">${j.status}</span>
|
|
|
|
|
+ <span class="stats">${stats.map(esc).join(" · ")}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="bar ${running?"":"static"}"><i style="width:${j.progress||0}%"></i></div>
|
|
|
|
|
+ ${j.error?`<div class="errline">${esc(j.error)}</div>`:""}
|
|
|
|
|
+ <div class="btns">
|
|
|
|
|
+ ${j.status==="done"?`<a class="btn sm gold" href="/api/download/${j.id}${API_TOKEN?"?token="+encodeURIComponent(API_TOKEN):""}" download>Download</a>`:""}
|
|
|
|
|
+ ${running?`<button class="btn sm danger" data-act="cancel">Cancel</button>`:""}
|
|
|
|
|
+ <button class="btn sm" data-act="log">${openLogs.has(j.id)?"Hide log":"Log"}</button>
|
|
|
|
|
+ ${!running?`<button class="btn sm danger" data-act="del">Delete</button>`:""}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <pre class="logbox ${openLogs.has(j.id)?"show":""}"></pre>`;
|
|
|
|
|
+ el.querySelectorAll("[data-act]").forEach(b=>b.onclick=async ()=>{
|
|
|
|
|
+ const act=b.dataset.act;
|
|
|
|
|
+ if(act==="cancel"){ await api(`/api/jobs/${j.id}/cancel`,{method:"POST"}); toast("Cancelling…"); }
|
|
|
|
|
+ if(act==="del"){ await api(`/api/jobs/${j.id}`,{method:"DELETE"}); toast("Job deleted"); }
|
|
|
|
|
+ if(act==="log"){ openLogs.has(j.id)?openLogs.delete(j.id):openLogs.add(j.id); }
|
|
|
|
|
+ refreshJobs();
|
|
|
|
|
+ });
|
|
|
|
|
+ if(openLogs.has(j.id)){
|
|
|
|
|
+ api(`/api/jobs/${j.id}/log`).then(r=>r.text()).then(t=>{
|
|
|
|
|
+ const lb=el.querySelector(".logbox"); lb.textContent=t; lb.scrollTop=lb.scrollHeight;
|
|
|
|
|
+ }).catch(()=>{});
|
|
|
|
|
+ }
|
|
|
|
|
+ box.appendChild(el);
|
|
|
|
|
+ });
|
|
|
|
|
+ }catch(e){/* transient */}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* ---------------- boot ---------------- */
|
|
|
|
|
+loadMeta(); refreshUploads(); refreshJobs(); updateStartState(); previewCmd();
|
|
|
|
|
+setInterval(refreshJobs,2000);
|
|
|
|
|
+setInterval(refreshUploads,15000);
|
|
|
|
|
+</script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|