2026-09-17 18:38:39 -07:00
<!doctype html>
2026-09-21 14:50:28 -07:00
< html lang = "en" data-theme = "dark" >
2026-09-17 18:38:39 -07:00
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width,initial-scale=1" >
< title > Shorts Autopilot</ title >
2026-09-21 14:50:28 -07:00
< 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" >
2026-09-17 18:38:39 -07:00
< style >
2026-09-21 14:50:28 -07:00
: 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 : 18 px ; --r-md : 12 px ; --r-sm : 8 px ;
}
[ 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 1 px 0 rgba ( 255 , 255 , 255 , .03 ) inset , 0 12 px 32 px -18 px 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 1 px 2 px rgba ( 28 , 31 , 58 , .05 ), 0 10 px 28 px -20 px rgba ( 28 , 31 , 58 , .35 );
}
2026-09-17 18:38:39 -07:00
* { box-sizing : border-box }
2026-09-21 14:50:28 -07:00
html , body { margin : 0 }
body { font-family : var ( -- font - ui ); font-size : 15 px ; line-height : 1.5 ; background : var ( -- bg ); color : var ( -- text ); -webkit- font-smoothing : antialiased }
a { color : var ( -- gold - text ); text-decoration-thickness : 1 px ; text-underline-offset : 3 px }
: focus-visible { outline : 2 px solid var ( -- gold ); outline-offset : 2 px ; border-radius : 6 px }
button { font : inherit ; color : inherit }
. topbar { position : sticky ; top : 0 ; z-index : 20 ; display : flex ; align-items : center ; gap : 24 px ; padding : 12 px 28 px ; background : color-mix ( in srgb , var ( -- bar ) 88 % , transparent ); backdrop-filter : blur ( 10 px ); border-bottom : 1 px solid var ( -- line - soft )}
. brand { display : flex ; align-items : center ; gap : 10 px ; font-family : var ( -- font - display ); font-weight : 800 ; font-size : 19 px ; letter-spacing : -.01 em ; white-space : nowrap }
. brand-mark { width : 28 px ; height : 28 px ; border-radius : 9 px ; background : var ( -- gold ); display : grid ; place-items : center ; color : var ( -- on - gold )}
. tabs { display : flex ; gap : 4 px ; flex : 1 }
. tabs button { background : none ; border : 0 ; padding : 8 px 14 px ; border-radius : 999 px ; color : var ( -- muted ); cursor : pointer ; font-size : 15 px }
. 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 : 38 px ; height : 38 px ; border-radius : 999 px ; border : 1 px 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 : 1180 px ; margin : 0 auto ; padding : 28 px 28 px 120 px }
2026-09-17 18:38:39 -07:00
. tab { display : none }. tab . active { display : block }
2026-09-21 14:50:28 -07:00
h2 , h3 { font-family : var ( -- font - display ); letter-spacing : -.01 em ; margin : 0 }
. muted { color : var ( -- muted )}
. small { font-size : 13 px }
. btn { display : inline-flex ; align-items : center ; gap : 8 px ; border : 1 px solid var ( -- line ); background : var ( -- surface ); color : var ( -- text ); padding : 9 px 16 px ; border-radius : 999 px ; cursor : pointer ; font-size : 14 px ; 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 : 6 px 12 px ; font-size : 13 px }
. console { display : grid ; grid-template-columns : auto 1 fr ; gap : 32 px ; align-items : center ; padding : 28 px 32 px ; background : var ( -- surface ); border : 1 px solid var ( -- line - soft ); border-radius : var ( -- r - lg ); box-shadow : var ( -- shadow )}
. go { position : relative ; width : 128 px ; height : 128 px ; border-radius : 50 % ; border : 0 ; cursor : pointer ; font-family : var ( -- font - display ); font-weight : 800 ; font-size : 28 px ; letter-spacing : .02 em ; background : var ( -- gold ); color : var ( -- on - gold ); box-shadow : 0 0 0 8 px color-mix ( in srgb , var ( -- gold ) 16 % , transparent ), 0 18 px 40 px -16 px color-mix ( in srgb , var ( -- gold ) 70 % , transparent ); transition : background .25 s , box-shadow .25 s , transform .12 s }
. go : hover { transform : scale ( 1.02 )}
. go : active { transform : scale ( .98 )}
. go . running { background : var ( -- coral ); color : #fff ; box-shadow : 0 0 0 8 px color-mix ( in srgb , var ( -- coral ) 18 % , transparent ), 0 18 px 40 px -16 px color-mix ( in srgb , var ( -- coral ) 70 % , transparent )}
. go . running :: after { content : "" ; position : absolute ; inset : -8 px ; border-radius : 50 % ; border : 2 px solid var ( -- coral ); opacity : 0 ; animation : pulse 2.4 s ease-out infinite }
. go . busy { background : var ( -- surface -2 ); color : var ( -- muted ); box-shadow : 0 0 0 8 px 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 : 10 px ; flex-wrap : wrap }
. stage { font-family : var ( -- font - display ); font-weight : 700 ; font-size : 28 px ; line-height : 1.15 ; letter-spacing : -.015 em ; margin : 0 ; overflow-wrap : anywhere }
. stage :: first-letter { text-transform : uppercase }
. chip { display : inline-flex ; align-items : center ; gap : 6 px ; padding : 3 px 10 px ; border-radius : 999 px ; font-size : 12.5 px ; 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 : 16 px ; display : none }
. progress . show { display : block }
. segs { display : flex ; gap : 4 px ; height : 10 px }
. segs i { flex : 1 ; border-radius : 3 px ; 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 : 8 px ; font-size : 13 px ; color : var ( -- muted ); font-variant-numeric : tabular-nums }
. idle-note { margin-top : 8 px ; color : var ( -- muted ); font-size : 14 px }
. facts { display : grid ; grid-template-columns : minmax ( 230 px , 1.5 fr ) repeat ( 5 , minmax ( 0 , 1 fr )); gap : 0 ; margin-top : 20 px ; border : 1 px solid var ( -- line - soft ); border-radius : var ( -- r - md ); overflow : hidden ; background : var ( -- surface )}
. fact { padding : 14 px 18 px ; border-right : 1 px solid var ( -- line - soft ); min-width : 0 }
. fact : last-child { border-right : 0 }
. fact dt { font-size : 13 px ; color : var ( -- muted ); margin-bottom : 4 px }
. fact dd { margin : 0 ; font-weight : 700 ; font-size : 15 px ; 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 : 12 px 16 px ; flex-wrap : wrap ; margin : 36 px 0 12 px }
. section-head h2 { font-size : 22 px }
. section-head . actions { display : flex ; gap : 8 px ; flex-wrap : wrap }
. table-wrap { background : var ( -- surface ); border : 1 px solid var ( -- line - soft ); border-radius : var ( -- r - md ); overflow-x : auto }
table { width : 100 % ; min-width : 760 px ; border-collapse : collapse ; font-size : 14 px }
th { font-weight : 700 ; color : var ( -- muted ); text-align : left ; font-size : 13 px ; padding : 12 px 16 px ; border-bottom : 1 px solid var ( -- line - soft ); white-space : nowrap }
td { padding : 14 px 16 px ; border-bottom : 1 px 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.5 px ; color : var ( -- muted ); margin-top : 6 px ; overflow-wrap : anywhere }
td . sub . notes { color : var ( -- amber )}
td . sub . err { color : var ( -- coral )}
2026-09-26 20:39:54 -07:00
. 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 )}. st-interrupted { --c : var ( -- muted )}
2026-09-21 14:50:28 -07:00
. links { display : flex ; gap : 6 px }
. empty { padding : 36 px 16 px ; 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 : 10 px 12 px ; background : var ( -- sunken ); border : 1 px solid var ( -- line ); border-radius : var ( -- r - sm ); color : var ( -- text ); font : inherit ; font-size : 14.5 px }
select { appearance : none ; background-image : linear-gradient ( 45 deg , transparent 50 % , var ( -- muted ) 50 % ), linear-gradient ( 135 deg , var ( -- muted ) 50 % , transparent 50 % ); background-position : calc ( 100 % - 17 px ) 55 % , calc ( 100 % - 12 px ) 55 % ; background-size : 5 px 5 px , 5 px 5 px ; background-repeat : no-repeat ; padding-right : 34 px }
input : focus , select : focus { outline : none ; border-color : var ( -- gold ); box-shadow : 0 0 0 3 px color-mix ( in srgb , var ( -- gold ) 22 % , transparent )}
input : disabled , select : disabled { opacity : .55 }
. settings { display : grid ; grid-template-columns : 220 px 1 fr ; gap : 36 px ; align-items : start }
. snav { position : sticky ; top : 84 px ; display : flex ; flex-direction : column ; gap : 2 px }
. snav a { display : block ; padding : 8 px 12 px ; border-radius : var ( -- r - sm ); color : var ( -- muted ); text-decoration : none ; font-size : 14.5 px }
. 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 3 px 0 0 var ( -- gold )}
. profile-bar { display : flex ; gap : 12 px ; align-items : end ; flex-wrap : wrap ; padding : 18 px 20 px ; background : var ( -- surface ); border : 1 px solid var ( -- line - soft ); border-radius : var ( -- r - md ); margin-bottom : 28 px }
. profile-bar . grow { flex : 1 ; min-width : 220 px }
. profile-bar . lbl { font-size : 13 px ; color : var ( -- muted ); margin-bottom : 6 px }
. group { padding : 4 px 0 28 px ; margin-bottom : 28 px ; border-bottom : 1 px solid var ( -- line - soft ); scroll-margin-top : 90 px }
. group : last-child { border-bottom : 0 }
. group h3 { font-size : 20 px }
. group . desc { color : var ( -- muted ); font-size : 14 px ; margin : 4 px 0 18 px }
. fields { display : grid ; grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 20 px 24 px }
. field . wide { grid-column : 1 /- 1 }
. field . lbl { font-weight : 700 ; font-size : 14 px ; margin-bottom : 6 px }
. field . hint { font-size : 13 px ; color : var ( -- muted ); margin-top : 6 px }
. field . hint . path { font-family : var ( -- font - mono ); font-size : 12 px ; overflow-wrap : anywhere }
. field { transition : opacity .2 s }
. toggle { display : flex ; align-items : flex-start ; gap : 12 px ; cursor : pointer }
. toggle input { position : absolute ; opacity : 0 ; pointer-events : none }
. track { flex : 0 0 auto ; width : 40 px ; height : 24 px ; border-radius : 999 px ; background : var ( -- line ); position : relative ; transition : background .2 s ; margin-top : 1 px }
. track :: after { content : "" ; position : absolute ; top : 3 px ; left : 3 px ; width : 18 px ; height : 18 px ; border-radius : 50 % ; background : #fff ; box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , .3 ); transition : transform .2 s }
. toggle input : checked + . track { background : var ( -- gold )}
. toggle input : checked + . track :: after { transform : translateX ( 16 px )}
. toggle input : focus-visible + . track { outline : 2 px solid var ( -- gold ); outline-offset : 2 px }
. toggle input : disabled + . track { opacity : .5 }
. toggle . tl { font-weight : 700 ; font-size : 14 px }
. days { display : flex ; gap : 6 px ; flex-wrap : wrap }
. day input { position : absolute ; opacity : 0 ; pointer-events : none }
. day span { display : inline-block ; padding : 7 px 12 px ; border-radius : 999 px ; border : 1 px solid var ( -- line ); background : var ( -- sunken ); font-size : 13.5 px ; cursor : pointer ; min-width : 52 px ; 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 : 2 px solid var ( -- gold ); outline-offset : 2 px }
. 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 : 14 px 28 px ; background : color-mix ( in srgb , var ( -- bar ) 92 % , transparent ); backdrop-filter : blur ( 10 px ); border-top : 1 px solid var ( -- line - soft )}
. savebar . show { display : flex }
. savebar . inner { width : 100 % ; max-width : 1180 px ; display : flex ; align-items : center ; justify-content : flex-end ; gap : 14 px }
. saved { font-size : 14 px ; color : var ( -- muted )}
. saved . ok { color : var ( -- sage )}. saved . bad { color : var ( -- coral )}
. logbar { display : flex ; align-items : center ; gap : 10 px ; flex-wrap : wrap ; margin-bottom : 14 px }
. logbar . spacer { flex : 1 }
. check { display : flex ; align-items : center ; gap : 8 px ; color : var ( -- muted ); font-size : 14 px ; cursor : pointer }
. check input { accent-color : var ( -- gold ); width : 16 px ; height : 16 px }
# log { background : var ( -- sunken ); border : 1 px solid var ( -- line - soft ); border-radius : var ( -- r - md ); height : calc ( 100 vh - 230 px ); min-height : 320 px ; overflow : auto ; padding : 14 px 16 px ; font-family : var ( -- font - mono ); font-size : 12.5 px ; 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 )}
2026-09-21 15:06:21 -07:00
. storage-line { display : flex ; align-items : center ; gap : 8 px ; margin-top : 18 px ; flex-wrap : wrap }
. icon-btn . folder { width : 30 px ; height : 30 px }
. icon-btn . folder : hover { color : var ( -- gold - text )}
2026-09-21 14:58:52 -07:00
. tools { display : flex ; gap : 10 px ; flex-wrap : wrap ; align-items : center ; margin : 0 0 22 px }
. tools . status { font-size : 14 px ; color : var ( -- muted )}
# trending-panel { margin : 0 0 24 px }
# trending-panel . table-wrap { max-height : 420 px ; overflow : auto }
2026-09-21 14:50:28 -07:00
@ media ( max-width : 860px ) {
. topbar { padding : 10 px 16 px ; gap : 12 px }
. brand span { display : none }
main { padding : 20 px 16 px 120 px }
. console { grid-template-columns : 1 fr ; justify-items : start ; padding : 22 px }
. settings { grid-template-columns : 1 fr }
. snav { position : static ; flex-direction : row ; overflow-x : auto ; gap : 6 px }
. snav a { white-space : nowrap }
. fields { grid-template-columns : 1 fr }
. facts { grid-template-columns : 1 fr 1 fr }
. fact { border-right : 0 ; border-bottom : 1 px solid var ( -- line - soft )}
. fact : first-child { grid-column : 1 /- 1 }
. go { width : 104 px ; height : 104 px ; font-size : 24 px }
. stage { font-size : 24 px }
}
2026-09-17 18:38:39 -07:00
</ style >
</ head >
< body >
2026-09-21 14:50:28 -07:00
< 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" >
2026-09-17 18:38:39 -07:00
< button data-tab = "dash" class = "active" > Dashboard</ button >
< button data-tab = "settings" > Settings</ button >
< button data-tab = "logs" > Log</ button >
</ nav >
2026-09-21 14:50:28 -07:00
< 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 >
2026-09-17 18:38:39 -07:00
</ header >
< main >
< section id = "dash" class = "tab active" >
2026-09-21 14:50:28 -07:00
< div class = "console" >
< button id = "go" class = "go" aria-live = "polite" > GO</ button >
< div class = "state" >
2026-09-21 14:52:05 -07:00
< div class = "state-line" >< p class = "stage" id = "st-stage" > Idle</ p ></ div >
2026-09-21 14:50:28 -07:00
< 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 >
2026-09-17 18:38:39 -07:00
</ div >
2026-09-20 16:49:03 -07:00
</ div >
2026-09-21 14:50:28 -07:00
< 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 >
2026-09-17 18:38:39 -07:00
</ div >
</ div >
2026-09-21 14:50:28 -07:00
< div class = "table-wrap" >< table id = "jobs" ></ table ></ div >
2026-09-21 15:06:21 -07:00
< p class = "storage-line muted small" >< span id = "st-storage" ></ span >< button class = "icon-btn folder" id = "open-folder" aria-label = "Show the videos folder in Finder" title = "Show in Finder" >< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linejoin = "round" >< path d = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></ svg ></ button >< span id = "folder-msg" ></ span ></ p >
2026-09-17 18:38:39 -07:00
</ section >
< section id = "settings" class = "tab" >
2026-09-21 14:50:28 -07:00
< 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 >
2026-09-17 18:38:39 -07:00
</ div >
</ div >
</ section >
< section id = "logs" class = "tab" >
2026-09-21 14:50:28 -07:00
< 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 >
2026-09-17 18:38:39 -07:00
</ div >
2026-09-21 14:50:28 -07:00
< div id = "log" role = "log" aria-live = "off" ></ div >
2026-09-17 18:38:39 -07:00
</ section >
</ main >
2026-09-21 14:50:28 -07:00
< 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 >
2026-09-17 18:38:39 -07:00
< 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 = [
2026-09-21 14:50:28 -07:00
[ "" , "Channel and audience" , "header" , "Applies to the profile selected above" , "" ],
2026-09-17 18:38:39 -07:00
[ "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" ],
2026-09-20 18:40:12 -07:00
[ "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" ],
2026-09-17 18:38:39 -07:00
[ "upload_category_id" , "Upload category" , "select:" + CATS , "" , "p" ],
[ "privacy_status" , "Upload privacy" , "select:private|unlisted|public" , "" , "p" ],
2026-09-20 17:16:41 -07:00
[ "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" ],
2026-09-17 18:38:39 -07:00
[ "trending_query" , "Trending search query" , "text" , "Blank uses the general trending chart. e.g. kids animated story" , "p" ],
2026-09-20 18:40:12 -07:00
[ "trending_queries" , "Backup search queries" , "wide" , "Comma separated. Tried in order when the main query has nothing new left" , "p" ],
2026-09-20 20:10:20 -07:00
[ "trending_days" , "Search window (days)" , "select:3|7|14|30|60|90" , "Only used with a search query" , "p" ],
2026-09-17 18:38:39 -07:00
[ "category_id" , "Source category" , "select:=All|" + CATS , "Filters source videos" , "p" ],
2026-09-20 20:10:20 -07:00
[ "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" ],
2026-09-20 21:00:27 -07:00
[ "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" ],
2026-09-20 20:10:20 -07:00
[ "minutes_between_videos" , "Minutes between videos" , "select:0=No gap|30|60|120|180|240|360" , "" , "p" ],
2026-09-21 14:50:28 -07:00
[ "" , "Publishing schedule" , "header" , "When finished videos go public on this profile's channel" , "" ],
2026-09-21 13:33:47 -07:00
[ "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" ],
2026-09-21 14:50:28 -07:00
[ "" , "Working hours" , "header" , "Shared by all profiles" , "" ],
2026-09-21 13:33:47 -07:00
[ "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" ],
2026-09-21 14:50:28 -07:00
[ "" , "Folders and storage" , "header" , "Shared by all profiles" , "" ],
2026-09-20 16:03:48 -07:00
[ "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" ],
2026-09-17 18:38:39 -07:00
[ "delete_after_publish" , "Delete videos after publishing" , "checkbox" , "Removes the job folder once the upload succeeds instead of moving it to published" , "g" ],
2026-09-20 16:03:48 -07:00
[ "keep_clips" , "Keep individual clips" , "checkbox" , "Keeps the scene clips, images, and narration alongside final.mp4" , "g" ],
2026-09-20 16:44:32 -07:00
[ "auto_install" , "Install missing pieces automatically" , "checkbox" , "Downloads models and installs packages when a run needs them" , "g" ],
2026-09-21 14:50:28 -07:00
[ "" , "Video engine" , "header" , "Shared by all profiles" , "" ],
2026-09-20 16:03:48 -07:00
[ "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" ],
2026-09-20 20:10:20 -07:00
[ "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" ],
2026-09-20 16:44:32 -07:00
[ "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" ],
2026-09-20 20:10:20 -07:00
[ "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" ],
2026-09-20 16:44:32 -07:00
[ "tts_rate" , "Narration speed" , "number" , "Only used by the system voice. Words per minute" , "g" ],
2026-09-20 16:11:33 -07:00
[ "piper_model" , "Piper voice file" , "wide" , "Blank uses the first voice found in the voices folder under Models" , "g" ],
2026-09-20 16:03:48 -07:00
[ "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" ],
2026-09-26 21:17:39 -07:00
[ "" , "Performance" , "header" , "Shared by all profiles. Lower settings here if the Mac becomes slow or unstable" , "" ],
[ "low_memory" , "Low memory mode" , "checkbox" , "Generates images in smaller pieces. A little slower, much less memory. Leave on for Macs with 16 GB or less" , "g" ],
[ "free_models_between_stages" , "Free models between stages" , "checkbox" , "Unloads the writing model before images are drawn and the vision model after checks" , "g" ],
[ "cpu_threads" , "CPU threads" , "select:0=Automatic|2|4|6|8|10" , "Caps how many cores image generation uses so the rest of the Mac stays responsive" , "g" ],
2026-09-21 14:50:28 -07:00
[ "" , "Writing and timing" , "header" , "Shared by all profiles" , "" ],
2026-09-17 18:38:39 -07:00
[ "ollama_url" , "Ollama URL" , "text" , "" , "g" ],
2026-09-20 16:03:48 -07:00
[ "ollama_model" , "Ollama model" , "model:text" , "Writes scripts and tags. Models not installed yet are downloaded automatically" , "g" ],
2026-09-21 11:53:35 -07:00
[ "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" ],
2026-09-20 20:10:20 -07:00
[ "ollama_timeout" , "Ollama timeout (seconds)" , "select:120|300|600|900" , "Max wait per Ollama request before retrying" , "g" ],
2026-09-17 18:38:39 -07:00
[ "aspect_ratio" , "Aspect ratio" , "select:9:16|16:9" , "" , "g" ],
2026-09-20 20:10:20 -07:00
[ "target_seconds" , "Target length (seconds)" , "select:30|45|60|90|120|180" , "Split into scenes of the length below" , "g" ],
2026-09-21 16:21:35 -07:00
[ "clip_seconds" , "Seconds per scene" , "select:5|6|8|10|12" , "Sets how many scenes the story has and how long each narration line is. 8 gives 7 scenes" , "g" ],
[ "scene_timing" , "Scene timing" , "select:narration=Match the narration|fixed=Fixed length per scene" , "Match the narration moves to the next scene shortly after the voice finishes" , "g" ],
2026-09-20 20:10:20 -07:00
[ "error_cooldown_minutes" , "Wait after error (minutes)" , "select:1|5|10|15|30|60" , "" , "g" ]
2026-09-17 18:38:39 -07:00
];
2026-09-21 13:33:47 -07:00
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" )])}
2026-09-26 21:17:39 -07:00
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" , "cpu_threads" ]);
2026-09-17 18:38:39 -07:00
const $ = s => document . querySelector ( s );
const esc = s => String ( s ?? "" ). replace ( /[&<>"]/g , c =>({ "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ }[ 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 )]}
2026-09-21 14:50:28 -07:00
$ ( "#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 ));
2026-09-17 18:38:39 -07:00
function renderProfiles ( list , active ){
2026-09-21 14:50:28 -07:00
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 ]) } ">` }
2026-09-21 14:58:52 -07:00
let spy = null ; let lastChannel = "" ;
2026-09-17 18:38:39 -07:00
async function loadSettings (){
const data = await api ( "/api/settings" );
renderProfiles ( data . profiles , data . active );
2026-09-21 14:50:28 -07:00
const root = $ ( "#settings-grid" ); root . innerHTML = "" ;
const nav = $ ( "#snav" ); nav . innerHTML = "" ;
let grid = null , n = 0 ;
2026-09-17 18:38:39 -07:00
for ( const [ k , label , type , hint , scope ] of FIELDS ){
2026-09-21 14:50:28 -07:00
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" );
2026-09-21 14:58:52 -07:00
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 }
2026-09-21 14:50:28 -07:00
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 }
2026-09-17 18:38:39 -07:00
const s = scope === "g" ? data . global : data . profile ;
2026-09-21 14:50:28 -07:00
const d = document . createElement ( "div" ); d . className = "field" + ( type === "wide" || type === "days" ? " wide" : "" );
2026-09-17 18:38:39 -07:00
let extra = "" ;
2026-09-21 14:50:28 -07:00
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" );
2026-09-20 16:03:48 -07:00
syncFields ()}
2026-09-21 14:50:28 -07:00
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 }}
2026-09-20 16:03:48 -07:00
function syncFields (){
2026-09-17 22:58:33 -07:00
const kids = $ ( "#f-made_for_kids" );
2026-09-21 14:50:28 -07:00
if ( kids ){ dim ([ "#f-kids_manual_review" , "#f-kids_llm_review" ], ! kids . checked ); kids . onchange = syncFields }
2026-09-21 13:33:47 -07:00
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 }
2026-09-20 20:10:20 -07:00
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" )})}
2026-09-20 16:03:48 -07:00
const eng = $ ( "#f-video_engine" );
2026-09-21 14:50:28 -07:00
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 );
2026-09-20 16:03:48 -07:00
eng . onchange = syncFields }}
2026-09-21 14:50:28 -07:00
2026-09-17 18:38:39 -07:00
async function saveSettings (){
const body = { global : {}, profile : {}};
2026-09-21 14:50:28 -07:00
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" ];
2026-09-21 13:33:47 -07:00
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 }
2026-09-21 14:50:28 -07:00
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 }
2026-09-17 18:38:39 -07:00
async function activate ( id ){ try { await api ( "/api/profiles/activate" ,{ method : "POST" , body : JSON . stringify ({ id })})} catch ( e ){ alert ( e . message )} await loadSettings (); refresh ()}
2026-09-21 14:50:28 -07:00
2026-09-26 20:39:54 -07:00
const STATUS_TEXT = { review : "Private, needs review" , published : "Published" , failed : "Failed" , running : "Running" , cancelled : "Cancelled" , scheduled : "Scheduled" , interrupted : "Interrupted" };
2026-09-21 14:50:28 -07:00
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" })}
2026-09-17 18:38:39 -07:00
async function refresh (){
try {
const s = await api ( "/api/status" );
2026-09-21 14:50:28 -07:00
const stage = s . stage || "idle" ;
$ ( "#st-stage" ). textContent = stage ;
2026-09-20 16:03:48 -07:00
$ ( "#st-engine" ). textContent = s . engine === "local" ? "Local (free)" : "Veo (paid)" ;
2026-09-21 14:50:28 -07:00
$ ( "#st-today" ). textContent = s . published_today + ( s . videos_per_day > 0 ? " of " + s . videos_per_day : "" );
2026-09-17 18:38:39 -07:00
$ ( "#st-channel" ). textContent = s . channel || "Not linked" ;
2026-09-21 14:50:28 -07:00
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 );
2026-09-17 18:38:39 -07:00
$ ( "#st-after" ). textContent = s . delete_after_publish ? "Delete files" : "Move to published" ;
2026-09-21 14:50:28 -07:00
$ ( "#st-storage" ). textContent = s . storage ? "Videos are stored in " + s . storage : "" ;
const pg = $ ( "#progress" ); const note = $ ( "#idle-note" );
2026-09-20 16:49:03 -07:00
if ( s . steps > 0 && ( s . running || s . step < s . steps )){
2026-09-21 14:50:28 -07:00
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" : "" });
2026-09-20 16:49:03 -07:00
const pct = Math . min ( 100 , Math . round ( s . step * 100 / s . steps ));
2026-09-21 14:50:28 -07:00
$ ( "#pg-meta" ). textContent = `Step ${ s . step } of ${ s . steps } , ${ pct } % done` + ( s . elapsed ? `, running for ${ fmtDur ( s . elapsed ) } ` : "" );
2026-09-26 22:56:19 -07:00
} else { pg . classList . remove ( "show" ); note . hidden = false ; note . textContent = s . running ? ( s . stage && s . stage !== "idle" ? "" : "Waiting for the next video to start." ) : "Press GO to start making videos for the active profile." }
2026-09-21 14:58:52 -07:00
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" }
2026-09-20 16:03:48 -07:00
setButton ( s . running );
2026-09-21 14:50:28 -07:00
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>` ;
2026-09-17 18:38:39 -07:00
} catch ( e ){}}
2026-09-21 14:50:28 -07:00
2026-09-21 16:18:31 -07:00
let since = 0 , polling = false ;
2026-09-17 18:38:39 -07:00
async function pollLogs (){
2026-09-21 16:18:31 -07:00
if ( polling ) return ; polling = true ;
2026-09-17 18:38:39 -07:00
try {
const r = await api ( "/api/logs?since=" + since ); const box = $ ( "#log" );
2026-09-21 16:18:31 -07:00
for ( const [ seq , level , line ] of r . lines ){ if ( seq <= since ) continue ; const d = document . createElement ( "div" ); d . className = level ; d . textContent = line ; box . appendChild ( d ); since = seq }
2026-09-17 18:38:39 -07:00
while ( box . childElementCount > 3000 ) box . firstChild . remove ();
if ( r . lines . length && $ ( "#autoscroll" ). checked ) box . scrollTop = box . scrollHeight
2026-09-21 16:18:31 -07:00
} catch ( e ){} finally { polling = false }}
2026-09-21 14:50:28 -07:00
2026-09-17 18:38:39 -07:00
document . querySelectorAll ( ".prof-select" ). forEach ( sel => sel . onchange = e => activate ( e . target . value ));
$ ( "#new-profile" ). onclick = async ()=>{
2026-09-21 14:50:28 -07:00
const name = prompt ( "Name for the new profile. It starts as a copy of the current profile's settings." );
2026-09-17 18:38:39 -07:00
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 || "" ;
2026-09-21 14:50:28 -07:00
if ( ! confirm ( `Delete the profile " ${ label } " and its linked channel? Videos already on disk are kept.` )) return ;
2026-09-17 18:38:39 -07:00
try { await api ( "/api/profiles/delete" ,{ method : "POST" , body : JSON . stringify ({ id : sel . value })}); await loadSettings (); refresh ()} catch ( e ){ alert ( e . message )}};
2026-09-21 14:50:28 -07:00
2026-09-20 16:03:48 -07:00
function setButton ( running ){
const b = $ ( "#go" );
if ( b . dataset . busy === "1" ) return ;
b . classList . toggle ( "running" , running );
b . textContent = running ? "STOP" : "GO" ;
2026-09-21 14:50:28 -07:00
b . setAttribute ( "aria-label" , running ? "Stop making videos" : "Start making videos" );
2026-09-20 16:03:48 -07:00
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" );
2026-09-21 14:50:28 -07:00
b . textContent = "..." ;
2026-09-20 16:03:48 -07:00
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 ()};
2026-09-21 14:58:52 -07:00
async function linkClick (){
if ( lastChannel ){
2026-09-20 16:03:48 -07:00
if ( ! confirm ( "Unlink the YouTube channel from this profile?" )) return ;
2026-09-21 14:58:52 -07:00
await api ( "/auth/unlink" ,{ method : "POST" }); lastChannel = "" ; await loadSettings (); refresh ()}
else { window . location . href = "/auth/start" }}
2026-09-17 18:38:39 -07:00
$ ( "#save" ). onclick = saveSettings ;
2026-09-21 15:06:21 -07:00
$ ( "#open-folder" ). onclick = async ()=>{ const m = $ ( "#folder-msg" ); try { await api ( "/api/open-storage" ,{ method : "POST" }); m . textContent = "" } catch ( e ){ m . textContent = e . message }};
2026-09-17 18:38:39 -07:00
$ ( "#clear" ). onclick = ()=>{ $ ( "#log" ). innerHTML = "" };
2026-09-20 16:11:33 -07:00
$ ( "#copy-log" ). onclick = async ()=>{
2026-09-21 14:50:28 -07:00
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 )};
2026-09-20 16:11:33 -07:00
$ ( "#clear-jobs" ). onclick = async ()=>{
2026-09-21 14:50:28 -07:00
const b = $ ( "#clear-jobs" ); if ( b . disabled ) return ; b . disabled = true ;
2026-09-20 18:47:04 -07:00
try { await api ( "/api/history/clear" ,{ method : "POST" }); await refresh ()} catch ( e ){ alert ( e . message )}
b . disabled = false };
2026-09-21 14:58:52 -07:00
async function previewClick (){
2026-09-21 14:50:28 -07:00
const panel = $ ( "#trending-panel" ); const m = $ ( "#preview-msg" ); const b = $ ( "#preview" );
panel . hidden = false ; m . textContent = "Loading" ; b . disabled = true ;
2026-09-17 18:38:39 -07:00
try {
const r = await api ( "/api/trending" );
2026-09-21 14:50:28 -07:00
$ ( "#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 }
2026-09-21 14:58:52 -07:00
b . disabled = false }
2026-09-21 14:50:28 -07:00
let startTab = "dash" ; try { const t = localStorage . getItem ( "sa-tab" ); if ([ "dash" , "settings" , "logs" ]. includes ( t )) startTab = t } catch ( e ){}
showTab ( startTab );
2026-09-17 18:38:39 -07:00
loadSettings (); refresh (); pollLogs ();
setInterval ( refresh , 3000 ); setInterval ( pollLogs , 2000 );
</ script >
</ body >
</ html >