Files
shorts-autopilot/static/index.html
T

529 lines
41 KiB
HTML

<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Shorts Autopilot</title>
<script>
(function(){try{var t=localStorage.getItem("sa-theme");if(t!=="light"&&t!=="dark"){t=window.matchMedia&&window.matchMedia("(prefers-color-scheme: light)").matches?"light":"dark"}document.documentElement.setAttribute("data-theme",t)}catch(e){}})();
</script>
<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=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700;12..96,800&display=swap" rel="stylesheet">
<style>
:root{
--font-ui:"Atkinson Hyperlegible",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
--font-display:"Bricolage Grotesque","Avenir Next",-apple-system,BlinkMacSystemFont,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--r-lg:18px;--r-md:12px;--r-sm:8px;
}
[data-theme="dark"]{
color-scheme:dark;
--bg:#14162a;--bar:#171a31;--surface:#1d2040;--surface-2:#252953;--sunken:#101226;--line:#2e3263;--line-soft:#252852;
--text:#eceaf6;--muted:#9d9fc4;--faint:#6e7199;
--gold:#f5b83d;--gold-text:#f5b83d;--on-gold:#221a05;
--sage:#6fcf97;--coral:#ff7a70;--sky:#7cb8ff;--violet:#b69cff;--amber:#ffb86b;
--shadow:0 1px 0 rgba(255,255,255,.03) inset,0 12px 32px -18px rgba(0,0,0,.6);
}
[data-theme="light"]{
color-scheme:light;
--bg:#f5f6fb;--bar:#ffffff;--surface:#ffffff;--surface-2:#eef0f8;--sunken:#f0f1f8;--line:#dfe1ee;--line-soft:#eaebf4;
--text:#1c1f3a;--muted:#5f6388;--faint:#8e91b0;
--gold:#f2ae2b;--gold-text:#946000;--on-gold:#221a05;
--sage:#1d8150;--coral:#cf3f35;--sky:#2c68c9;--violet:#6a4dcf;--amber:#a65f00;
--shadow:0 1px 2px rgba(28,31,58,.05),0 10px 28px -20px rgba(28,31,58,.35);
}
*{box-sizing:border-box}
html,body{margin:0}
body{font-family:var(--font-ui);font-size:15px;line-height:1.5;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased}
a{color:var(--gold-text);text-decoration-thickness:1px;text-underline-offset:3px}
:focus-visible{outline:2px solid var(--gold);outline-offset:2px;border-radius:6px}
button{font:inherit;color:inherit}
.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:24px;padding:12px 28px;background:color-mix(in srgb,var(--bar) 88%,transparent);backdrop-filter:blur(10px);border-bottom:1px solid var(--line-soft)}
.brand{display:flex;align-items:center;gap:10px;font-family:var(--font-display);font-weight:800;font-size:19px;letter-spacing:-.01em;white-space:nowrap}
.brand-mark{width:28px;height:28px;border-radius:9px;background:var(--gold);display:grid;place-items:center;color:var(--on-gold)}
.tabs{display:flex;gap:4px;flex:1}
.tabs button{background:none;border:0;padding:8px 14px;border-radius:999px;color:var(--muted);cursor:pointer;font-size:15px}
.tabs button:hover{color:var(--text);background:var(--surface-2)}
.tabs button.active{color:var(--text);background:var(--surface-2);font-weight:700}
.icon-btn{width:38px;height:38px;border-radius:999px;border:1px solid var(--line);background:var(--surface);display:grid;place-items:center;cursor:pointer;color:var(--muted)}
.icon-btn:hover{color:var(--text);border-color:var(--faint)}
.icon-btn .sun{display:none}
[data-theme="light"] .icon-btn .sun{display:block}
[data-theme="light"] .icon-btn .moon{display:none}
main{max-width:1180px;margin:0 auto;padding:28px 28px 120px}
.tab{display:none}.tab.active{display:block}
h2,h3{font-family:var(--font-display);letter-spacing:-.01em;margin:0}
.muted{color:var(--muted)}
.small{font-size:13px}
.btn{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--line);background:var(--surface);color:var(--text);padding:9px 16px;border-radius:999px;cursor:pointer;font-size:14px;text-decoration:none;white-space:nowrap}
.btn:hover{border-color:var(--faint)}
.btn:disabled{opacity:.5;cursor:default}
.btn.primary{background:var(--gold);border-color:var(--gold);color:var(--on-gold);font-weight:700}
.btn.primary:hover{filter:brightness(1.05)}
.btn.danger{color:var(--coral);border-color:color-mix(in srgb,var(--coral) 45%,var(--line))}
.btn.quiet{border-color:transparent;background:transparent;color:var(--muted)}
.btn.quiet:hover{color:var(--text);background:var(--surface-2)}
.btn.sm{padding:6px 12px;font-size:13px}
.console{display:grid;grid-template-columns:auto 1fr;gap:32px;align-items:center;padding:28px 32px;background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-lg);box-shadow:var(--shadow)}
.go{position:relative;width:128px;height:128px;border-radius:50%;border:0;cursor:pointer;font-family:var(--font-display);font-weight:800;font-size:28px;letter-spacing:.02em;background:var(--gold);color:var(--on-gold);box-shadow:0 0 0 8px color-mix(in srgb,var(--gold) 16%,transparent),0 18px 40px -16px color-mix(in srgb,var(--gold) 70%,transparent);transition:background .25s,box-shadow .25s,transform .12s}
.go:hover{transform:scale(1.02)}
.go:active{transform:scale(.98)}
.go.running{background:var(--coral);color:#fff;box-shadow:0 0 0 8px color-mix(in srgb,var(--coral) 18%,transparent),0 18px 40px -16px color-mix(in srgb,var(--coral) 70%,transparent)}
.go.running::after{content:"";position:absolute;inset:-8px;border-radius:50%;border:2px solid var(--coral);opacity:0;animation:pulse 2.4s ease-out infinite}
.go.busy{background:var(--surface-2);color:var(--muted);box-shadow:0 0 0 8px var(--line-soft);cursor:progress}
.go:disabled{cursor:progress}
@keyframes pulse{0%{transform:scale(1);opacity:.55}100%{transform:scale(1.28);opacity:0}}
@media (prefers-reduced-motion:reduce){.go.running::after{animation:none}.go{transition:none}}
.state{min-width:0}
.state-line{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.stage{font-family:var(--font-display);font-weight:700;font-size:28px;line-height:1.15;letter-spacing:-.015em;margin:0;overflow-wrap:anywhere}
.stage::first-letter{text-transform:uppercase}
.chip{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;font-size:12.5px;font-weight:700;background:color-mix(in srgb,var(--c,var(--muted)) 16%,transparent);color:var(--c,var(--muted));white-space:nowrap}
.progress{margin-top:16px;display:none}
.progress.show{display:block}
.segs{display:flex;gap:4px;height:10px}
.segs i{flex:1;border-radius:3px;background:var(--line-soft)}
.segs i.on{background:var(--gold)}
.segs i.now{background:color-mix(in srgb,var(--gold) 45%,var(--line-soft))}
.pg-meta{margin-top:8px;font-size:13px;color:var(--muted);font-variant-numeric:tabular-nums}
.idle-note{margin-top:8px;color:var(--muted);font-size:14px}
.facts{display:grid;grid-template-columns:minmax(230px,1.5fr) repeat(5,minmax(0,1fr));gap:0;margin-top:20px;border:1px solid var(--line-soft);border-radius:var(--r-md);overflow:hidden;background:var(--surface)}
.fact{padding:14px 18px;border-right:1px solid var(--line-soft);min-width:0}
.fact:last-child{border-right:0}
.fact dt{font-size:13px;color:var(--muted);margin-bottom:4px}
.fact dd{margin:0;font-weight:700;font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fact dd.warn{color:var(--amber)}
.fact select{font-weight:700}
.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px 16px;flex-wrap:wrap;margin:36px 0 12px}
.section-head h2{font-size:22px}
.section-head .actions{display:flex;gap:8px;flex-wrap:wrap}
.table-wrap{background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-md);overflow-x:auto}
table{width:100%;min-width:760px;border-collapse:collapse;font-size:14px}
th{font-weight:700;color:var(--muted);text-align:left;font-size:13px;padding:12px 16px;border-bottom:1px solid var(--line-soft);white-space:nowrap}
td{padding:14px 16px;border-bottom:1px solid var(--line-soft);vertical-align:top}
tr:last-child td{border-bottom:0}
td.nowrap{white-space:nowrap;font-variant-numeric:tabular-nums}
td.title{font-weight:700}
td .sub{font-size:12.5px;color:var(--muted);margin-top:6px;overflow-wrap:anywhere}
td .sub.notes{color:var(--amber)}
td .sub.err{color:var(--coral)}
.st-published{--c:var(--sage)}.st-failed{--c:var(--coral)}.st-running{--c:var(--gold-text)}.st-review{--c:var(--violet)}.st-scheduled{--c:var(--sky)}.st-cancelled{--c:var(--muted)}
.links{display:flex;gap:6px}
.empty{padding:36px 16px;text-align:center;color:var(--muted)}
.source a{color:var(--text);text-decoration-color:var(--line)}
.source a:hover{text-decoration-color:var(--gold)}
label{display:block}
input[type=text],input[type=password],input[type=number],select{width:100%;padding:10px 12px;background:var(--sunken);border:1px solid var(--line);border-radius:var(--r-sm);color:var(--text);font:inherit;font-size:14.5px}
select{appearance:none;background-image:linear-gradient(45deg,transparent 50%,var(--muted) 50%),linear-gradient(135deg,var(--muted) 50%,transparent 50%);background-position:calc(100% - 17px) 55%,calc(100% - 12px) 55%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:34px}
input:focus,select:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 3px color-mix(in srgb,var(--gold) 22%,transparent)}
input:disabled,select:disabled{opacity:.55}
.settings{display:grid;grid-template-columns:220px 1fr;gap:36px;align-items:start}
.snav{position:sticky;top:84px;display:flex;flex-direction:column;gap:2px}
.snav a{display:block;padding:8px 12px;border-radius:var(--r-sm);color:var(--muted);text-decoration:none;font-size:14.5px}
.snav a:hover{color:var(--text);background:var(--surface-2)}
.snav a.active{color:var(--text);background:var(--surface-2);font-weight:700;box-shadow:inset 3px 0 0 var(--gold)}
.profile-bar{display:flex;gap:12px;align-items:end;flex-wrap:wrap;padding:18px 20px;background:var(--surface);border:1px solid var(--line-soft);border-radius:var(--r-md);margin-bottom:28px}
.profile-bar .grow{flex:1;min-width:220px}
.profile-bar .lbl{font-size:13px;color:var(--muted);margin-bottom:6px}
.group{padding:4px 0 28px;margin-bottom:28px;border-bottom:1px solid var(--line-soft);scroll-margin-top:90px}
.group:last-child{border-bottom:0}
.group h3{font-size:20px}
.group .desc{color:var(--muted);font-size:14px;margin:4px 0 18px}
.fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 24px}
.field.wide{grid-column:1/-1}
.field .lbl{font-weight:700;font-size:14px;margin-bottom:6px}
.field .hint{font-size:13px;color:var(--muted);margin-top:6px}
.field .hint.path{font-family:var(--font-mono);font-size:12px;overflow-wrap:anywhere}
.field{transition:opacity .2s}
.toggle{display:flex;align-items:flex-start;gap:12px;cursor:pointer}
.toggle input{position:absolute;opacity:0;pointer-events:none}
.track{flex:0 0 auto;width:40px;height:24px;border-radius:999px;background:var(--line);position:relative;transition:background .2s;margin-top:1px}
.track::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.3);transition:transform .2s}
.toggle input:checked+.track{background:var(--gold)}
.toggle input:checked+.track::after{transform:translateX(16px)}
.toggle input:focus-visible+.track{outline:2px solid var(--gold);outline-offset:2px}
.toggle input:disabled+.track{opacity:.5}
.toggle .tl{font-weight:700;font-size:14px}
.days{display:flex;gap:6px;flex-wrap:wrap}
.day input{position:absolute;opacity:0;pointer-events:none}
.day span{display:inline-block;padding:7px 12px;border-radius:999px;border:1px solid var(--line);background:var(--sunken);font-size:13.5px;cursor:pointer;min-width:52px;text-align:center}
.day input:checked+span{background:color-mix(in srgb,var(--gold) 22%,transparent);border-color:var(--gold);color:var(--text);font-weight:700}
.day input:focus-visible+span{outline:2px solid var(--gold);outline-offset:2px}
.day input:disabled+span{opacity:.5;cursor:default}
.savebar{position:fixed;left:0;right:0;bottom:0;z-index:15;display:none;justify-content:center;padding:14px 28px;background:color-mix(in srgb,var(--bar) 92%,transparent);backdrop-filter:blur(10px);border-top:1px solid var(--line-soft)}
.savebar.show{display:flex}
.savebar .inner{width:100%;max-width:1180px;display:flex;align-items:center;justify-content:flex-end;gap:14px}
.saved{font-size:14px;color:var(--muted)}
.saved.ok{color:var(--sage)}.saved.bad{color:var(--coral)}
.logbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px}
.logbar .spacer{flex:1}
.check{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:14px;cursor:pointer}
.check input{accent-color:var(--gold);width:16px;height:16px}
#log{background:var(--sunken);border:1px solid var(--line-soft);border-radius:var(--r-md);height:calc(100vh - 230px);min-height:320px;overflow:auto;padding:14px 16px;font-family:var(--font-mono);font-size:12.5px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere}
#log .ERROR,#log .CRITICAL{color:var(--coral)}
#log .WARNING{color:var(--amber)}
#log .DEBUG{color:var(--faint)}
.tools{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:0 0 22px}
.tools .status{font-size:14px;color:var(--muted)}
#trending-panel{margin:0 0 24px}
#trending-panel .table-wrap{max-height:420px;overflow:auto}
@media (max-width:860px){
.topbar{padding:10px 16px;gap:12px}
.brand span{display:none}
main{padding:20px 16px 120px}
.console{grid-template-columns:1fr;justify-items:start;padding:22px}
.settings{grid-template-columns:1fr}
.snav{position:static;flex-direction:row;overflow-x:auto;gap:6px}
.snav a{white-space:nowrap}
.fields{grid-template-columns:1fr}
.facts{grid-template-columns:1fr 1fr}
.fact{border-right:0;border-bottom:1px solid var(--line-soft)}
.fact:first-child{grid-column:1/-1}
.go{width:104px;height:104px;font-size:24px}
.stage{font-size:24px}
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="brand-mark" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 14 14"><path d="M3 1.8v10.4c0 .6.7 1 1.2.7l8.3-5.2c.5-.3.5-1.1 0-1.4L4.2 1.1C3.7.8 3 1.2 3 1.8z" fill="currentColor"/></svg></div><span>Shorts Autopilot</span></div>
<nav class="tabs" aria-label="Sections">
<button data-tab="dash" class="active">Dashboard</button>
<button data-tab="settings">Settings</button>
<button data-tab="logs">Log</button>
</nav>
<button class="icon-btn" id="theme" aria-label="Switch between light and dark mode" title="Switch theme">
<svg class="moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
<svg class="sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4.5"/><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"/></svg>
</button>
</header>
<main>
<section id="dash" class="tab active">
<div class="console">
<button id="go" class="go" aria-live="polite">GO</button>
<div class="state">
<div class="state-line"><p class="stage" id="st-stage">Idle</p></div>
<div class="progress" id="progress"><div class="segs" id="pg-segs"></div><div class="pg-meta" id="pg-meta"></div></div>
<div class="idle-note" id="idle-note">Press GO to start making videos for the active profile.</div>
</div>
</div>
<dl class="facts">
<div class="fact"><dt>Active profile</dt><dd><select id="prof-dash" class="prof-select" aria-label="Active profile"></select></dd></div>
<div class="fact"><dt>YouTube channel</dt><dd id="st-channel">Not linked</dd></div>
<div class="fact"><dt>Published today</dt><dd id="st-today">0</dd></div>
<div class="fact"><dt>Video engine</dt><dd id="st-engine"></dd></div>
<div class="fact"><dt>Free space</dt><dd id="st-free">?</dd></div>
<div class="fact"><dt>After publishing</dt><dd id="st-after"></dd></div>
</dl>
<div class="section-head">
<h2>Jobs</h2>
<div class="actions">
<button class="btn sm quiet" id="clear-jobs">Clear list</button>
</div>
</div>
<div class="table-wrap"><table id="jobs"></table></div>
<p class="muted small" id="st-storage" style="margin-top:18px"></p>
</section>
<section id="settings" class="tab">
<div class="settings">
<nav class="snav" id="snav" aria-label="Settings sections"></nav>
<div>
<div class="profile-bar">
<div class="grow"><div class="lbl">Editing profile</div><select id="prof-set" class="prof-select" aria-label="Editing profile"></select></div>
<button class="btn" id="new-profile">New profile</button>
<button class="btn danger" id="del-profile">Delete profile</button>
</div>
<div id="settings-grid"></div>
</div>
</div>
</section>
<section id="logs" class="tab">
<div class="logbar">
<button class="btn sm" id="copy-log">Copy log</button>
<button class="btn sm" id="clear">Clear view</button>
<a class="btn sm quiet" href="/api/logs/download">Download full log</a>
<span class="muted small" id="copy-msg"></span>
<span class="spacer"></span>
<label class="check"><input type="checkbox" id="autoscroll" checked>Follow new lines</label>
</div>
<div id="log" role="log" aria-live="off"></div>
</section>
</main>
<div class="savebar" id="savebar"><div class="inner"><span class="saved" id="saved"></span><button class="btn primary" id="save">Save settings</button></div></div>
<script>
const CATS="1=Film and Animation|2=Autos and Vehicles|10=Music|15=Pets and Animals|17=Sports|19=Travel and Events|20=Gaming|22=People and Blogs|23=Comedy|24=Entertainment|25=News and Politics|26=Howto and Style|27=Education|28=Science and Technology";
const FIELDS=[
["","Channel and audience","header","Applies to the profile selected above",""],
["name","Profile name","text","","p"],
["channel_niche","Channel niche","wide","Steers every script, e.g. 60 second animated movies for kids ages 3 to 7","p"],
["made_for_kids","Made for kids","checkbox","Flags uploads as made for kids and enforces child-safe rules, word filters, LLM compliance review, and frame vision checks","p"],
["kids_manual_review","Hold kids videos for manual review","checkbox","Forces made-for-kids uploads to private so you can watch them before publishing","p"],
["kids_llm_review","LLM script review","select:advisory=Advisory (show notes, you review)|block=Block (reject flagged scripts)|off=Off","Advisory logs notes without blocking. Word filters and vision checks always block","p"],
["upload_category_id","Upload category","select:"+CATS,"","p"],
["privacy_status","Upload privacy","select:private|unlisted|public","","p"],
["ai_disclosure","Disclose AI generated content","checkbox","YouTube only requires this for realistic synthetic content, not cartoons. Leave on to be transparent with parents","p"],
["trending_query","Trending search query","text","Blank uses the general trending chart. e.g. kids animated story","p"],
["trending_queries","Backup search queries","wide","Comma separated. Tried in order when the main query has nothing new left","p"],
["trending_days","Search window (days)","select:3|7|14|30|60|90","Only used with a search query","p"],
["category_id","Source category","select:=All|"+CATS,"Filters source videos","p"],
["region_code","Region","select:US=United States|GB=United Kingdom|CA=Canada|AU=Australia|NZ=New Zealand|IE=Ireland|IN=India|PH=Philippines|ZA=South Africa|DE=Germany|FR=France|ES=Spain|MX=Mexico|BR=Brazil|JP=Japan","","p"],
["source_language","Source language","select:en=English|es=Spanish|fr=French|de=German|pt=Portuguese|it=Italian|=Any language","Skips source videos in other languages","p"],
["trending_count","Videos to scan","select:10|25|50","","p"],
["videos_per_day","Videos per day","select:0=Unlimited|1|2|3|4|5|6|8|10|12|15|20","The default YouTube API quota allows about 6 uploads a day. Higher values need a quota increase from Google","p"],
["minutes_between_videos","Minutes between videos","select:0=No gap|30|60|120|180|240|360","","p"],
["","Publishing schedule","header","When finished videos go public on this profile's channel",""],
["publish_mode","When to publish","select:immediate=As soon as it is uploaded|scheduled=At set days and times","Scheduled videos upload as private and YouTube makes them public at the next open time slot","p"],
["publish_timezone","Publishing time zone","select:America/New_York=Eastern (US and Canada)|America/Chicago=Central (US and Canada)|America/Denver=Mountain (US and Canada)|America/Phoenix=Arizona|America/Los_Angeles=Pacific (US and Canada)|America/Anchorage=Alaska|Pacific/Honolulu=Hawaii|America/Halifax=Atlantic (Canada)|America/Mexico_City=Mexico City|Europe/London=London|Europe/Berlin=Central Europe|Asia/Kolkata=India|Australia/Sydney=Sydney|UTC=UTC","Times below are in this zone, pick your audience's","p"],
["publish_days","Publishing days","days","","p"],
["publish_time_1","Publish time 1","time","","p"],
["publish_time_2","Publish time 2","time:opt","Optional","p"],
["publish_time_3","Publish time 3","time:opt","Optional","p"],
["","Working hours","header","Shared by all profiles",""],
["work_hours_enabled","Only work during set hours","checkbox","Outside these hours no new video is started. A video already in progress finishes first. Uses this computer's clock","g"],
["work_days","Working days","days","","g"],
["work_start","Start work at","time","","g"],
["work_end","Stop starting new videos at","time","If this is earlier than the start time, the window runs overnight","g"],
["","Folders and storage","header","Shared by all profiles",""],
["storage_dir","Storage folder","wide","Full path on a large disk, e.g. /Volumes/iDrive/shorts-autopilot. Jobs are built in output/ and moved to published/","g"],
["models_dir","Models folder","wide","Where AI models download to (several GB). Blank puts them in the storage folder","g"],
["temp_dir","Temp folder","wide","Scratch space used while rendering. Blank puts it in the storage folder","g"],
["delete_after_publish","Delete videos after publishing","checkbox","Removes the job folder once the upload succeeds instead of moving it to published","g"],
["keep_clips","Keep individual clips","checkbox","Keeps the scene clips, images, and narration alongside final.mp4","g"],
["auto_install","Install missing pieces automatically","checkbox","Downloads models and installs packages when a run needs them","g"],
["","Video engine","header","Shared by all profiles",""],
["video_engine","Video engine","select:local=Local (free: AI images, narration, motion)|veo=Veo (paid Google API)","Local runs entirely on this machine at no cost. Veo makes true AI video but needs billing","g"],
["sd_model","Image model","select:stabilityai/sdxl-turbo=Fast (SDXL Turbo)|stabilityai/stable-diffusion-xl-base-1.0=Best quality (SDXL Base, slower)","Picking a model fills in its recommended steps, guidance, and size. First use downloads about 7 GB","g"],
["sd_steps","Image steps","select:4|6|8|20|25|30","4 to 8 for Fast, 20 to 30 for Best quality","g"],
["sd_guidance","Image guidance","select:2|3|5|7|8","2 for Fast, 7 for Best quality","g"],
["sd_width","Image width","select:640|704|768|832","Larger is slower","g"],
["sd_height","Image height","select:1152|1216|1344|1472","Keep close to a 9:16 ratio with the width","g"],
["tts_engine","Narration engine","select:auto=Auto (best available)|kokoro=Kokoro (most natural)|piper=Piper|say=macOS say|espeak=espeak-ng","Missing engines are installed automatically on the next run","g"],
["tts_voice","Narration voice","select:af_heart=Heart (US female, warm)|af_bella=Bella (US female)|af_nicole=Nicole (US female, soft)|af_sarah=Sarah (US female)|af_sky=Sky (US female)|am_michael=Michael (US male)|am_adam=Adam (US male)|bf_emma=Emma (UK female)|bf_isabella=Isabella (UK female)|bm_george=George (UK male)|bm_lewis=Lewis (UK male)","Kokoro voices. The system voice ignores this","g"],
["tts_rate","Narration speed","number","Only used by the system voice. Words per minute","g"],
["piper_model","Piper voice file","wide","Blank uses the first voice found in the voices folder under Models","g"],
["gemini_api_key","Gemini API key (Veo)","password","Only needed for the Veo engine. Billing must be enabled","g"],
["veo_model","Veo model","text","e.g. veo-3.1-fast-generate-preview","g"],
["","Writing and timing","header","Shared by all profiles",""],
["ollama_url","Ollama URL","text","","g"],
["ollama_model","Ollama model","model:text","Writes scripts and tags. Models not installed yet are downloaded automatically","g"],
["vision_model","Ollama vision model","model:vision","Checks every scene on made-for-kids profiles. qwen2.5vl:7b is recommended, moondream is too weak to judge anatomy","g"],
["ollama_timeout","Ollama timeout (seconds)","select:120|300|600|900","Max wait per Ollama request before retrying","g"],
["aspect_ratio","Aspect ratio","select:9:16|16:9","","g"],
["target_seconds","Target length (seconds)","select:30|45|60|90|120|180","Split into scenes of the length below","g"],
["clip_seconds","Seconds per scene","select:5|6|8|10|12","8 gives 7 scenes in a 60 second Short","g"],
["error_cooldown_minutes","Wait after error (minutes)","select:1|5|10|15|30|60","","g"]
];
const DAYS=[["mon","Mon"],["tue","Tue"],["wed","Wed"],["thu","Thu"],["fri","Fri"],["sat","Sat"],["sun","Sun"]];
const TIMES=[];for(let h=0;h<24;h++)for(const m of [0,30]){const v=String(h).padStart(2,"0")+":"+String(m).padStart(2,"0");const hr=h%12||12;TIMES.push([v,hr+":"+String(m).padStart(2,"0")+(h<12?" AM":" PM")])}
const NUMERIC=new Set(["sd_steps","sd_guidance","sd_width","sd_height","trending_days","trending_count","videos_per_day","minutes_between_videos","target_seconds","clip_seconds","ollama_timeout","error_cooldown_minutes","tts_rate"]);
const $=s=>document.querySelector(s);
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
async function api(path,opts={}){const r=await fetch(path,{headers:{"Content-Type":"application/json"},...opts});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.detail||r.statusText);return j}
function opt(o){const i=o.indexOf("=");return i<0?[o,o]:[o.slice(0,i),o.slice(i+1)]}
$("#theme").onclick=()=>{const next=document.documentElement.getAttribute("data-theme")==="light"?"dark":"light";document.documentElement.setAttribute("data-theme",next);try{localStorage.setItem("sa-theme",next)}catch(e){}};
function showTab(name){
document.querySelectorAll(".tabs button").forEach(x=>x.classList.toggle("active",x.dataset.tab===name));
document.querySelectorAll(".tab").forEach(x=>x.classList.toggle("active",x.id===name));
$("#savebar").classList.toggle("show",name==="settings");
try{localStorage.setItem("sa-tab",name)}catch(e){}}
document.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>showTab(b.dataset.tab));
function renderProfiles(list,active){
document.querySelectorAll(".prof-select").forEach(sel=>{sel.innerHTML=list.map(p=>`<option value="${esc(p.id)}" ${p.id===active?"selected":""}>${esc(p.name)}${p.channel&&p.channel!==p.name?" ("+esc(p.channel)+")":""}</option>`).join("")})}
function fieldInput(k,type,s,data){
if(type.startsWith("select:")){const list=type.slice(7).split("|").map(opt);const cur=String(s[k]??"");if(cur!==""&&!list.some(([v])=>v===cur))list.unshift([cur,cur+" (current)"]);return `<select id="f-${k}">${list.map(([v,l])=>`<option value="${esc(v)}" ${cur===v?"selected":""}>${esc(l)}</option>`).join("")}</select>`}
if(type==="days"){const on=new Set(String(s[k]||"").split(","));return `<div class="days" id="f-${k}">${DAYS.map(([v,l])=>`<label class="day"><input type="checkbox" value="${v}" ${on.has(v)?"checked":""}><span>${l}</span></label>`).join("")}</div>`}
if(type.startsWith("time")){const cur=String(s[k]||"");const opts=(type==="time:opt"?[["","None"]]:[]).concat(TIMES);if(cur&&!opts.some(([v])=>v===cur))opts.push([cur,cur]);return `<select id="f-${k}">${opts.map(([v,l])=>`<option value="${esc(v)}" ${cur===v?"selected":""}>${esc(l)}</option>`).join("")}</select>`}
if(type.startsWith("model:")){
const kind=type.slice(6);
const all=data.installed_models||[];const vis=new Set(data.installed_vision_models||[]);
const installed=kind==="vision"?all.filter(m=>vis.has(m)):all.filter(m=>!vis.has(m));
const suggested=(kind==="vision"?data.suggested_vision_models:data.suggested_text_models)||[];
const cur=String(s[k]||"");const seen=new Set();const opts=[];
for(const m of installed){if(seen.has(m))continue;seen.add(m);opts.push(`<option value="${esc(m)}" ${cur===m?"selected":""}>${esc(m)}</option>`)}
for(const m of suggested){if(seen.has(m)||seen.has(m+":latest"))continue;seen.add(m);opts.push(`<option value="${esc(m)}" ${cur===m?"selected":""}>${esc(m)} (downloads on next run)</option>`)}
if(cur&&!seen.has(cur))opts.unshift(`<option value="${esc(cur)}" selected>${esc(cur)} (current)</option>`);
return `<select id="f-${k}">${opts.join("")}</select>`}
return `<input type="${type==="wide"?"text":type}" id="f-${k}" value="${esc(s[k])}">`}
let spy=null;let lastChannel="";
async function loadSettings(){
const data=await api("/api/settings");
renderProfiles(data.profiles,data.active);
const root=$("#settings-grid");root.innerHTML="";
const nav=$("#snav");nav.innerHTML="";
let grid=null,n=0;
for(const [k,label,type,hint,scope] of FIELDS){
if(type==="header"){
n++;const sec=document.createElement("section");sec.className="group";sec.id="sec-"+n;
sec.innerHTML=`<h3>${esc(label)}</h3>${hint?`<p class="desc">${esc(hint)}</p>`:""}<div class="fields"></div>`;
root.appendChild(sec);grid=sec.querySelector(".fields");
if(n===1){const tools=document.createElement("div");tools.innerHTML=`<div class="tools"><button class="btn sm" id="link">${lastChannel?"Unlink YouTube channel":"Link YouTube channel"}</button><button class="btn sm" id="preview">Preview trending videos</button><span class="status" id="link-status">${lastChannel?"Linked to "+esc(lastChannel):"No channel linked yet"}</span></div><div id="trending-panel" hidden><div class="section-head" style="margin:0 0 10px"><h3 style="font-size:16px">Trending for this profile's search</h3><span class="muted small" id="preview-msg"></span></div><div class="table-wrap"><table id="trending"></table></div></div>`;sec.insertBefore(tools,grid);$("#link").onclick=linkClick;$("#preview").onclick=previewClick}
const a=document.createElement("a");a.href="#sec-"+n;a.textContent=label;a.dataset.sec="sec-"+n;
a.onclick=e=>{e.preventDefault();sec.scrollIntoView({behavior:matchMedia("(prefers-reduced-motion: reduce)").matches?"auto":"smooth",block:"start"})};
nav.appendChild(a);continue}
const s=scope==="g"?data.global:data.profile;
const d=document.createElement("div");d.className="field"+(type==="wide"||type==="days"?" wide":"");
let extra="";
if(k==="storage_dir")extra=`<div class="hint path">${esc(data.storage_resolved)}</div>`;
if(k==="models_dir")extra=`<div class="hint path">${esc(data.models_resolved)}</div>`;
if(k==="temp_dir")extra=`<div class="hint path">${esc(data.temp_resolved)}</div>`;
if(type==="checkbox"){
d.innerHTML=`<label class="toggle"><input type="checkbox" id="f-${k}" ${s[k]?"checked":""}><span class="track"></span><span><span class="tl">${esc(label)}</span>${hint?`<span class="hint" style="display:block">${esc(hint)}</span>`:""}</span></label>`}
else{
d.innerHTML=`<label class="lbl" for="f-${k}">${esc(label)}</label>${fieldInput(k,type,s,data)}${hint?`<div class="hint">${esc(hint)}</div>`:""}${extra}`}
grid.appendChild(d)}
if(spy)spy.disconnect();
spy=new IntersectionObserver(es=>{for(const e of es){if(e.isIntersecting){nav.querySelectorAll("a").forEach(a=>a.classList.toggle("active",a.dataset.sec===e.target.id))}}},{rootMargin:"-90px 0px -65% 0px"});
root.querySelectorAll(".group").forEach(g=>spy.observe(g));
const first=nav.querySelector("a");if(first)first.classList.add("active");
syncFields()}
function dim(ids,off){for(const id of ids){const el=$(id);if(!el)continue;el.querySelectorAll("input").forEach(x=>x.disabled=off);if(el.tagName!=="DIV")el.disabled=off;const f=el.closest(".field");if(f)f.style.opacity=off?.5:1}}
function syncFields(){
const kids=$("#f-made_for_kids");
if(kids){dim(["#f-kids_manual_review","#f-kids_llm_review"],!kids.checked);kids.onchange=syncFields}
const wh=$("#f-work_hours_enabled");
if(wh){dim(["#f-work_days","#f-work_start","#f-work_end"],!wh.checked);wh.onchange=syncFields}
const pm=$("#f-publish_mode");
if(pm){dim(["#f-publish_timezone","#f-publish_days","#f-publish_time_1","#f-publish_time_2","#f-publish_time_3"],pm.value!=="scheduled");pm.onchange=syncFields}
const sdm=$("#f-sd_model");
if(sdm&&!sdm.dataset.wired){sdm.dataset.wired="1";sdm.addEventListener("change",()=>{
const best=sdm.value.includes("stable-diffusion-xl-base");
const set=(id,v)=>{const el=$(id);if(!el)return;if(![...el.options].some(o=>o.value===v)){const o=document.createElement("option");o.value=v;o.textContent=v;el.appendChild(o)}el.value=v};
set("#f-sd_steps",best?"25":"4");set("#f-sd_guidance",best?"7":"2");set("#f-sd_width",best?"768":"704");set("#f-sd_height",best?"1344":"1216")})}
const eng=$("#f-video_engine");
if(eng){const local=eng.value==="local";
dim(["#f-sd_model","#f-sd_steps","#f-sd_guidance","#f-sd_width","#f-sd_height","#f-tts_engine","#f-tts_voice","#f-tts_rate","#f-piper_model"],!local);
dim(["#f-gemini_api_key","#f-veo_model"],local);
eng.onchange=syncFields}}
async function saveSettings(){
const body={global:{},profile:{}};
for(const [k,,type,,scope] of FIELDS){if(type==="header")continue;const el=$("#f-"+k);if(!el)continue;const dest=body[scope==="g"?"global":"profile"];
if(type==="days"){dest[k]=[...el.querySelectorAll("input:checked")].map(x=>x.value).join(",");continue}
dest[k]=type==="checkbox"?el.checked:(type==="number"||NUMERIC.has(k))?Number(el.value):el.value}
const msg=$("#saved");const btn=$("#save");btn.disabled=true;
try{await api("/api/settings",{method:"POST",body:JSON.stringify(body)});await loadSettings();msg.className="saved ok";msg.textContent="Saved at "+new Date().toLocaleTimeString([], {hour:"numeric",minute:"2-digit"});refresh()}
catch(e){msg.className="saved bad";msg.textContent=e.message}
btn.disabled=false}
async function activate(id){try{await api("/api/profiles/activate",{method:"POST",body:JSON.stringify({id})})}catch(e){alert(e.message)}await loadSettings();refresh()}
const STATUS_TEXT={review:"Private, needs review",published:"Published",failed:"Failed",running:"Running",cancelled:"Cancelled",scheduled:"Scheduled"};
function fmtDur(d){d=Math.max(0,d|0);const h=Math.floor(d/3600),m=Math.floor(d%3600/60),s=d%60;return h?`${h}h ${m}m`:m?`${m}m ${s}s`:`${s}s`}
function fmtStart(t){if(!t)return"";const d=new Date(t.replace(" ","T"));if(isNaN(d))return t;return d.toLocaleString([], {month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}
async function refresh(){
try{
const s=await api("/api/status");
const stage=s.stage||"idle";
$("#st-stage").textContent=stage;
$("#st-engine").textContent=s.engine==="local"?"Local (free)":"Veo (paid)";
$("#st-today").textContent=s.published_today+(s.videos_per_day>0?" of "+s.videos_per_day:"");
$("#st-channel").textContent=s.channel||"Not linked";
const free=$("#st-free");free.textContent=s.storage_free_gb===null?"Folder not created":s.storage_free_gb.toLocaleString()+" GB";free.classList.toggle("warn",s.storage_free_gb!==null&&s.storage_free_gb<10);
$("#st-after").textContent=s.delete_after_publish?"Delete files":"Move to published";
$("#st-storage").textContent=s.storage?"Videos are stored in "+s.storage:"";
const pg=$("#progress");const note=$("#idle-note");
if(s.steps>0&&(s.running||s.step<s.steps)){
pg.classList.add("show");note.hidden=true;
const segs=$("#pg-segs");if(segs.childElementCount!==s.steps){segs.innerHTML="<i></i>".repeat(s.steps)}
[...segs.children].forEach((el,i)=>{el.className=i<s.step-1?"on":i===s.step-1?"now":""});
const pct=Math.min(100,Math.round(s.step*100/s.steps));
$("#pg-meta").textContent=`Step ${s.step} of ${s.steps}, ${pct}% done`+(s.elapsed?`, running for ${fmtDur(s.elapsed)}`:"");
}else{pg.classList.remove("show");note.hidden=false;note.textContent=s.running?"Waiting for the next video to start.":"Press GO to start making videos for the active profile."}
lastChannel=s.channel||"";const lb=$("#link");if(lb){lb.textContent=s.channel?"Unlink YouTube channel":"Link YouTube channel"}const ls=$("#link-status");if(ls){ls.textContent=s.channel?"Linked to "+s.channel:"No channel linked yet"}
setButton(s.running);
const dur=h=>{if(!h.started_ts)return"";const end=h.finished_ts||(h.status==="running"?s.now:0);return end?fmtDur(end-h.started_ts):""};
const rows=s.history.map(h=>{
const label=h.status==="scheduled"&&h.publish_at?"Scheduled for "+new Date(h.publish_at).toLocaleString([], {weekday:"short",month:"short",day:"numeric",hour:"numeric",minute:"2-digit"}):(STATUS_TEXT[h.status]||h.status);
return `<tr><td class="nowrap">${esc(fmtStart(h.started))}</td><td class="nowrap">${esc(dur(h))}</td><td class="title">${esc(h.title||"Writing script")}<div class="sub">${esc(h.profile)}</div></td><td class="source"><a target="_blank" rel="noopener" href="https://youtube.com/watch?v=${esc(h.source_id)}">${esc(h.source_title)}</a></td><td><span class="chip st-${esc(h.status)}">${esc(label)}</span>${h.notes?`<div class="sub notes">Reviewer notes: ${esc(h.notes)}</div>`:""}${h.error?`<div class="sub err">${esc(h.error)}</div>`:""}</td><td>${h.youtube_id?`<div class="links"><a class="btn sm" target="_blank" rel="noopener" href="https://studio.youtube.com/video/${esc(h.youtube_id)}/edit">Studio</a><a class="btn sm" target="_blank" rel="noopener" href="https://youtube.com/shorts/${esc(h.youtube_id)}">Watch</a></div>`:""}</td></tr>`}).join("");
$("#jobs").innerHTML=`<thead><tr><th>Started</th><th>Time</th><th>Video</th><th>Inspired by</th><th>Status</th><th></th></tr></thead><tbody>${rows||`<tr><td colspan="6" class="empty">No jobs yet. Press GO and finished videos will show up here.</td></tr>`}</tbody>`;
}catch(e){}}
let since=0;
async function pollLogs(){
try{
const r=await api("/api/logs?since="+since);const box=$("#log");
for(const [seq,level,line] of r.lines){const d=document.createElement("div");d.className=level;d.textContent=line;box.appendChild(d);since=seq}
while(box.childElementCount>3000)box.firstChild.remove();
if(r.lines.length&&$("#autoscroll").checked)box.scrollTop=box.scrollHeight
}catch(e){}}
document.querySelectorAll(".prof-select").forEach(sel=>sel.onchange=e=>activate(e.target.value));
$("#new-profile").onclick=async()=>{
const name=prompt("Name for the new profile. It starts as a copy of the current profile's settings.");
if(!name)return;
try{await api("/api/profiles",{method:"POST",body:JSON.stringify({name})});await loadSettings();refresh()}catch(e){alert(e.message)}};
$("#del-profile").onclick=async()=>{
const sel=$("#prof-set");const label=sel.options[sel.selectedIndex]?.text||"";
if(!confirm(`Delete the profile "${label}" and its linked channel? Videos already on disk are kept.`))return;
try{await api("/api/profiles/delete",{method:"POST",body:JSON.stringify({id:sel.value})});await loadSettings();refresh()}catch(e){alert(e.message)}};
function setButton(running){
const b=$("#go");
if(b.dataset.busy==="1")return;
b.classList.toggle("running",running);
b.textContent=running?"STOP":"GO";
b.setAttribute("aria-label",running?"Stop making videos":"Start making videos");
b.disabled=false}
$("#go").onclick=async()=>{
const b=$("#go");const running=b.classList.contains("running");
b.dataset.busy="1";b.disabled=true;b.classList.remove("running");b.classList.add("busy");
b.textContent="...";
try{await api(running?"/api/stop":"/api/start",{method:"POST"})}catch(e){alert(e.message)}
b.dataset.busy="";b.classList.remove("busy");
setButton(!running);
refresh()};
async function linkClick(){
if(lastChannel){
if(!confirm("Unlink the YouTube channel from this profile?"))return;
await api("/auth/unlink",{method:"POST"});lastChannel="";await loadSettings();refresh()}
else{window.location.href="/auth/start"}}
$("#save").onclick=saveSettings;
$("#clear").onclick=()=>{$("#log").innerHTML=""};
$("#copy-log").onclick=async()=>{
const text=$("#log").innerText.trim();const m=$("#copy-msg");
if(!text){m.textContent="Nothing to copy";return}
try{await navigator.clipboard.writeText(text);m.textContent="Copied "+text.split("\n").length+" lines"}
catch(e){const ta=document.createElement("textarea");ta.value=text;document.body.appendChild(ta);ta.select();try{document.execCommand("copy");m.textContent="Copied"}catch(err){m.textContent="Could not copy"}ta.remove()}
setTimeout(()=>{m.textContent=""},3000)};
$("#clear-jobs").onclick=async()=>{
const b=$("#clear-jobs");if(b.disabled)return;b.disabled=true;
try{await api("/api/history/clear",{method:"POST"});await refresh()}catch(e){alert(e.message)}
b.disabled=false};
async function previewClick(){
const panel=$("#trending-panel");const m=$("#preview-msg");const b=$("#preview");
panel.hidden=false;m.textContent="Loading";b.disabled=true;
try{
const r=await api("/api/trending");
$("#trending").innerHTML=`<thead><tr><th>Views</th><th>Title</th><th>Channel</th></tr></thead><tbody>${r.items.map(v=>`<tr><td class="nowrap">${v.views.toLocaleString()}</td><td class="source"><a target="_blank" rel="noopener" href="https://youtube.com/watch?v=${esc(v.id)}">${esc(v.title)}</a></td><td>${esc(v.channel)}</td></tr>`).join("")||`<tr><td colspan="3" class="empty">No videos matched the current search settings.</td></tr>`}</tbody>`;
m.textContent=r.items.length+" videos";
}catch(e){m.textContent=e.message}
b.disabled=false}
let startTab="dash";try{const t=localStorage.getItem("sa-tab");if(["dash","settings","logs"].includes(t))startTab=t}catch(e){}
showTab(startTab);
loadSettings();refresh();pollLogs();
setInterval(refresh,3000);setInterval(pollLogs,2000);
</script>
</body>
</html>