Add Jobs clear view, copy log, and latest local engine improvements
This commit is contained in:
+3
-3
@@ -1,15 +1,15 @@
|
|||||||
# Privacy Policy
|
# Privacy Policy
|
||||||
|
|
||||||
Shorts-autopilot is a personal tool run locally by its owner to create and upload videos to the owner's own YouTube channel.
|
Shorts Autopilot is a personal tool run locally by its owner to create and upload videos to the owner's own YouTube channel.
|
||||||
|
|
||||||
## Data accessed
|
## Data accessed
|
||||||
Shorts-autopilot uses the YouTube Data API to read public trending video information and to upload videos to the channel the owner authorizes.
|
Shorts Autopilot uses the YouTube Data API to read public trending video information and to upload videos to the channel the owner authorizes.
|
||||||
|
|
||||||
## Data storage
|
## Data storage
|
||||||
OAuth tokens and settings are stored only on the owner's local computer. No data is sent to any server other than Google, YouTube, and the AI services configured by the owner.
|
OAuth tokens and settings are stored only on the owner's local computer. No data is sent to any server other than Google, YouTube, and the AI services configured by the owner.
|
||||||
|
|
||||||
## Data sharing
|
## Data sharing
|
||||||
Shorts-autopilot does not sell, share, or transfer user data to third parties.
|
Shorts Autopilot does not sell, share, or transfer user data to third parties.
|
||||||
|
|
||||||
## Revoking access
|
## Revoking access
|
||||||
Access can be revoked at any time at https://myaccount.google.com/permissions
|
Access can be revoked at any time at https://myaccount.google.com/permissions
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ GLOBAL_DEFAULTS = {
|
|||||||
"sd_model": "stabilityai/sdxl-turbo",
|
"sd_model": "stabilityai/sdxl-turbo",
|
||||||
"sd_steps": 4,
|
"sd_steps": 4,
|
||||||
"sd_guidance": 0,
|
"sd_guidance": 0,
|
||||||
"sd_width": 512,
|
"sd_width": 768,
|
||||||
"sd_height": 896,
|
"sd_height": 1344,
|
||||||
"tts_engine": "auto",
|
"tts_engine": "auto",
|
||||||
"tts_voice": "Samantha",
|
"tts_voice": "Samantha",
|
||||||
"tts_rate": 170,
|
"tts_rate": 170,
|
||||||
@@ -131,9 +131,10 @@ IMAGE_NEGATIVE = (
|
|||||||
"scary, creepy, horror, violence, weapon, blood, gore, deformed, distorted, extra limbs, ugly, blurry, low quality, nsfw"
|
"scary, creepy, horror, violence, weapon, blood, gore, deformed, distorted, extra limbs, ugly, blurry, low quality, nsfw"
|
||||||
)
|
)
|
||||||
|
|
||||||
KIDS_VISION_PROMPT = """This is a still frame from a short video intended for young children.
|
KIDS_VISION_PROMPT = """Look carefully at this image from a video for young children.
|
||||||
Mark it unsafe if the image shows: realistic humans or children, violence, weapons, blood, injury, scary, creepy, or disturbing imagery, distorted or malformed faces or bodies, nudity or suggestive content, alcohol, tobacco, drugs, brand logos, readable text, or anything a parent would find inappropriate for a 4 year old.
|
First describe what you actually see in the image.
|
||||||
Return JSON only: {"safe": true or false, "reason": "short explanation"}"""
|
Then decide: it is unsafe if it shows realistic humans or children, violence, weapons, blood, injury, scary or creepy imagery, badly deformed or duplicated faces or bodies, nudity, alcohol, tobacco, drugs, brand logos, or anything a parent would find inappropriate for a 4 year old.
|
||||||
|
Reply with JSON only, filling in your own words: {"description": "what you see", "safe": true or false, "reason": "why"}"""
|
||||||
|
|
||||||
VISION_TEXT_PROMPT = """Look at this image, which is meant for young children.
|
VISION_TEXT_PROMPT = """Look at this image, which is meant for young children.
|
||||||
Answer with one word, safe or unsafe, then a short reason.
|
Answer with one word, safe or unsafe, then a short reason.
|
||||||
@@ -279,6 +280,28 @@ def free_gb(path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def voices_dir(s):
|
||||||
|
return resolve_sub(s, "models_dir", "models") / "voices"
|
||||||
|
|
||||||
|
|
||||||
|
def find_piper_voice(s):
|
||||||
|
configured = s["piper_model"].strip()
|
||||||
|
if configured and Path(configured).expanduser().exists():
|
||||||
|
return str(Path(configured).expanduser())
|
||||||
|
vdir = voices_dir(s)
|
||||||
|
if vdir.exists():
|
||||||
|
for f in sorted(vdir.glob("*.onnx")):
|
||||||
|
return str(f)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def piper_binary():
|
||||||
|
for cand in (BASE / ".venv" / "bin" / "piper", Path("/opt/homebrew/bin/piper")):
|
||||||
|
if cand.exists():
|
||||||
|
return str(cand)
|
||||||
|
return shutil.which("piper") or ""
|
||||||
|
|
||||||
|
|
||||||
def apply_paths(s):
|
def apply_paths(s):
|
||||||
models = writable(resolve_sub(s, "models_dir", "models"))
|
models = writable(resolve_sub(s, "models_dir", "models"))
|
||||||
temp = writable(resolve_sub(s, "temp_dir", "tmp"))
|
temp = writable(resolve_sub(s, "temp_dir", "tmp"))
|
||||||
@@ -649,14 +672,16 @@ Identify the underlying topic and why it appeals to viewers. Then write a comple
|
|||||||
Do not reuse the source's title, script, characters, jokes, branding, or channel identity.
|
Do not reuse the source's title, script, characters, jokes, branding, or channel identity.
|
||||||
Do not depict real people, celebrities, brands, logos, or copyrighted characters. Invent new characters.
|
Do not depict real people, celebrities, brands, logos, or copyrighted characters. Invent new characters.
|
||||||
|
|
||||||
|
First invent the cast. "characters" is one sentence naming each character with fixed, concrete visual details (species, color, size, clothing, one distinctive feature) that never change.
|
||||||
|
|
||||||
The Short has exactly {n} scenes of {clip} seconds each. Each scene has:
|
The Short has exactly {n} scenes of {clip} seconds each. Each scene has:
|
||||||
"visual": a detailed, self-contained shot description of one still image (subject, setting, action, mood, lighting). Repeat key character and setting details in every scene so they stay consistent. Never mention on-screen text or words.
|
"visual": a detailed, self-contained shot description of one still image (setting, action, mood, lighting). Name the characters but do not re-describe their appearance, that comes from "characters". Never mention on-screen text or words.
|
||||||
"narration": one spoken line of at most {words} words.
|
"narration": one spoken line of at most {words} words.
|
||||||
|
|
||||||
Scene 1 must hook the viewer in the first 2 seconds. The final scene must deliver a payoff.
|
Scene 1 must hook the viewer in the first 2 seconds. The final scene must deliver a payoff.
|
||||||
|
|
||||||
Return JSON only in this shape:
|
Return JSON only in this shape:
|
||||||
{{"title": "under 70 characters", "style": "one sentence visual style applied to every scene", "description": "2 to 3 sentence YouTube description", "scenes": [{{"visual": "...", "narration": "..."}}]}}"""
|
{{"title": "under 70 characters", "characters": "one sentence describing every character's fixed appearance", "style": "one sentence visual style applied to every scene", "description": "2 to 3 sentence YouTube description", "scenes": [{{"visual": "...", "narration": "..."}}]}}"""
|
||||||
last = None
|
last = None
|
||||||
for attempt in range(1, 4):
|
for attempt in range(1, 4):
|
||||||
check()
|
check()
|
||||||
@@ -673,6 +698,7 @@ Return JSON only in this shape:
|
|||||||
data["scenes"] = [{"visual": str(sc.get("visual", "")), "narration": str(sc.get("narration", ""))} for sc in scenes[:n]]
|
data["scenes"] = [{"visual": str(sc.get("visual", "")), "narration": str(sc.get("narration", ""))} for sc in scenes[:n]]
|
||||||
data["title"] = str(data["title"])
|
data["title"] = str(data["title"])
|
||||||
data["style"] = str(data.get("style", ""))
|
data["style"] = str(data.get("style", ""))
|
||||||
|
data["characters"] = str(data.get("characters", ""))
|
||||||
data["description"] = str(data.get("description", ""))
|
data["description"] = str(data.get("description", ""))
|
||||||
logger.info("Script ready: '%s' (%d scenes)", data["title"], n)
|
logger.info("Script ready: '%s' (%d scenes)", data["title"], n)
|
||||||
return data
|
return data
|
||||||
@@ -736,7 +762,7 @@ def normalize_text(t):
|
|||||||
|
|
||||||
|
|
||||||
def script_text(script):
|
def script_text(script):
|
||||||
parts = [script["title"], script["description"], script["style"]]
|
parts = [script["title"], script["description"], script["style"], script.get("characters", "")]
|
||||||
for sc in script["scenes"]:
|
for sc in script["scenes"]:
|
||||||
parts += [sc["visual"], sc["narration"]]
|
parts += [sc["visual"], sc["narration"]]
|
||||||
return " ".join(parts)
|
return " ".join(parts)
|
||||||
@@ -744,7 +770,7 @@ def script_text(script):
|
|||||||
|
|
||||||
def kids_rule_issues(script):
|
def kids_rule_issues(script):
|
||||||
issues = []
|
issues = []
|
||||||
texts = [script["title"], script["description"], script["style"]]
|
texts = [script["title"], script["description"], script["style"], script.get("characters", "")]
|
||||||
texts += [f"{sc['visual']} {sc['narration']}" for sc in script["scenes"]]
|
texts += [f"{sc['visual']} {sc['narration']}" for sc in script["scenes"]]
|
||||||
for t in texts:
|
for t in texts:
|
||||||
for m in KIDS_BANNED.finditer(t):
|
for m in KIDS_BANNED.finditer(t):
|
||||||
@@ -942,8 +968,8 @@ def generate_image(s, prompt, path):
|
|||||||
"prompt": prompt[:900],
|
"prompt": prompt[:900],
|
||||||
"num_inference_steps": max(1, int(s["sd_steps"])),
|
"num_inference_steps": max(1, int(s["sd_steps"])),
|
||||||
"guidance_scale": float(s["sd_guidance"]),
|
"guidance_scale": float(s["sd_guidance"]),
|
||||||
"width": int(s["sd_width"]),
|
"width": max(512, int(s["sd_width"]) // 64 * 64),
|
||||||
"height": int(s["sd_height"]),
|
"height": max(512, int(s["sd_height"]) // 64 * 64),
|
||||||
}
|
}
|
||||||
if kwargs["guidance_scale"] > 0:
|
if kwargs["guidance_scale"] > 0:
|
||||||
kwargs["negative_prompt"] = IMAGE_NEGATIVE
|
kwargs["negative_prompt"] = IMAGE_NEGATIVE
|
||||||
@@ -955,34 +981,45 @@ def generate_image(s, prompt, path):
|
|||||||
|
|
||||||
def tts_speak(s, text, out_wav):
|
def tts_speak(s, text, out_wav):
|
||||||
engine = s["tts_engine"] if s["tts_engine"] in TTS_ENGINES else "auto"
|
engine = s["tts_engine"] if s["tts_engine"] in TTS_ENGINES else "auto"
|
||||||
|
voice = find_piper_voice(s)
|
||||||
|
binary = piper_binary()
|
||||||
if engine == "auto":
|
if engine == "auto":
|
||||||
engine = "say" if sys.platform == "darwin" else ("piper" if s["piper_model"].strip() else "espeak")
|
if voice and binary:
|
||||||
|
engine = "piper"
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
engine = "say"
|
||||||
|
else:
|
||||||
|
engine = "espeak"
|
||||||
text = text.strip() or "..."
|
text = text.strip() or "..."
|
||||||
if engine == "say":
|
if engine == "piper":
|
||||||
|
if not voice:
|
||||||
|
raise RuntimeError("Piper needs a voice file. Run ./install-local.sh or set the path in Settings")
|
||||||
|
if not binary:
|
||||||
|
raise RuntimeError("Piper is not installed. Run ./install-local.sh")
|
||||||
|
r = subprocess.run([binary, "--model", voice, "--output_file", str(out_wav)], input=text, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"piper failed: {r.stderr.strip()[:200]}")
|
||||||
|
elif engine == "say":
|
||||||
aiff = out_wav.with_suffix(".aiff")
|
aiff = out_wav.with_suffix(".aiff")
|
||||||
cmd = ["say", "-r", str(int(s["tts_rate"])), "-o", str(aiff)]
|
cmd = ["say", "-r", str(int(s["tts_rate"])), "-o", str(aiff)]
|
||||||
if s["tts_voice"].strip():
|
if s["tts_voice"].strip():
|
||||||
cmd += ["-v", s["tts_voice"].strip()]
|
cmd += ["-v", s["tts_voice"].strip()]
|
||||||
cmd.append(text)
|
cmd.append(text)
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0 and s["tts_voice"].strip():
|
||||||
|
logger.warning("Voice '%s' is not installed, using the system default", s["tts_voice"].strip())
|
||||||
|
r = subprocess.run(["say", "-r", str(int(s["tts_rate"])), "-o", str(aiff), text], capture_output=True, text=True)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
raise RuntimeError(f"say failed: {r.stderr.strip()[:200]}")
|
raise RuntimeError(f"say failed: {r.stderr.strip()[:200]}")
|
||||||
conv = subprocess.run(["ffmpeg", "-y", "-i", str(aiff), "-ar", "44100", "-ac", "2", str(out_wav)], capture_output=True, text=True)
|
conv = subprocess.run(["ffmpeg", "-y", "-i", str(aiff), "-ar", "44100", "-ac", "2", str(out_wav)], capture_output=True, text=True)
|
||||||
aiff.unlink(missing_ok=True)
|
aiff.unlink(missing_ok=True)
|
||||||
if conv.returncode != 0:
|
if conv.returncode != 0:
|
||||||
raise RuntimeError("Could not convert narration audio")
|
raise RuntimeError("Could not convert narration audio")
|
||||||
elif engine == "espeak":
|
else:
|
||||||
voice = s["tts_voice"].strip() or "en-us"
|
v = s["tts_voice"].strip() or "en-us"
|
||||||
r = subprocess.run(["espeak-ng", "-v", voice, "-s", str(int(s["tts_rate"])), "-w", str(out_wav), text], capture_output=True, text=True)
|
r = subprocess.run(["espeak-ng", "-v", v, "-s", str(int(s["tts_rate"])), "-w", str(out_wav), text], capture_output=True, text=True)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
raise RuntimeError(f"espeak-ng failed: {r.stderr.strip()[:200]}")
|
raise RuntimeError(f"espeak-ng failed: {r.stderr.strip()[:200]}")
|
||||||
else:
|
|
||||||
model = s["piper_model"].strip()
|
|
||||||
if not model:
|
|
||||||
raise RuntimeError("Piper needs a voice model path in Settings")
|
|
||||||
r = subprocess.run(["piper", "--model", model, "--output_file", str(out_wav)], input=text, capture_output=True, text=True)
|
|
||||||
if r.returncode != 0:
|
|
||||||
raise RuntimeError(f"piper failed: {r.stderr.strip()[:200]}")
|
|
||||||
if not out_wav.exists() or out_wav.stat().st_size < 1000:
|
if not out_wav.exists() or out_wav.stat().st_size < 1000:
|
||||||
raise RuntimeError("Narration audio was empty")
|
raise RuntimeError("Narration audio was empty")
|
||||||
|
|
||||||
@@ -1024,7 +1061,9 @@ def make_clip_local(s, scene, script, path, kids):
|
|||||||
image = path.with_suffix(".png")
|
image = path.with_suffix(".png")
|
||||||
audio = path.with_suffix(".wav")
|
audio = path.with_suffix(".wav")
|
||||||
style = script["style"].strip()
|
style = script["style"].strip()
|
||||||
prompt = f"{scene['visual']} {style}{KIDS_IMAGE_SUFFIX if kids else ''}"
|
cast = script.get("characters", "").strip()
|
||||||
|
cast_part = f" Characters: {cast}" if cast else ""
|
||||||
|
prompt = f"{scene['visual']}{cast_part} {style}{KIDS_IMAGE_SUFFIX if kids else ''}"
|
||||||
logger.debug("Image prompt: %s", prompt)
|
logger.debug("Image prompt: %s", prompt)
|
||||||
generate_image(s, prompt, image)
|
generate_image(s, prompt, image)
|
||||||
tts_speak(s, scene["narration"], audio)
|
tts_speak(s, scene["narration"], audio)
|
||||||
@@ -1144,6 +1183,8 @@ def run_job(s):
|
|||||||
raise RuntimeError("Made for kids requires a vision model in Settings (e.g. llama3.2-vision)")
|
raise RuntimeError("Made for kids requires a vision model in Settings (e.g. llama3.2-vision)")
|
||||||
prepare_storage(s)
|
prepare_storage(s)
|
||||||
ensure_models(s, engine)
|
ensure_models(s, engine)
|
||||||
|
if engine == "local":
|
||||||
|
logger.info("Narration: %s | Image: %s at %sx%s", "piper" if find_piper_voice(s) and piper_binary() else s["tts_engine"], s["sd_model"], s["sd_width"], s["sd_height"])
|
||||||
logger.info("Profile: %s | Engine: %s | Niche: %s | Made for kids: %s | Upload category: %s", s["name"], engine, s["channel_niche"] or "none", kids, s["upload_category_id"])
|
logger.info("Profile: %s | Engine: %s | Niche: %s | Made for kids: %s | Upload category: %s", s["name"], engine, s["channel_niche"] or "none", kids, s["upload_category_id"])
|
||||||
if kids:
|
if kids:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1486,6 +1527,16 @@ async def api_pull_model(request: Request):
|
|||||||
return {"ok": True, "pulled": pulled, "models": ollama_tags(g)}
|
return {"ok": True, "pulled": pulled, "models": ollama_tags(g)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/history/clear")
|
||||||
|
def api_clear_history():
|
||||||
|
with state_lock:
|
||||||
|
st = load_state()
|
||||||
|
st["history"] = []
|
||||||
|
save_state(st)
|
||||||
|
logger.info("Cleared the job history")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/trending")
|
@app.get("/api/trending")
|
||||||
def api_trending():
|
def api_trending():
|
||||||
try:
|
try:
|
||||||
|
|||||||
+20
-6
@@ -77,7 +77,7 @@ a{color:#64b5f6}
|
|||||||
<span class="hint" id="preview-msg"></span>
|
<span class="hint" id="preview-msg"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel" id="trending-panel" style="display:none"><h3>Trending</h3><table id="trending"></table></div>
|
<div class="panel" id="trending-panel" style="display:none"><h3>Trending</h3><table id="trending"></table></div>
|
||||||
<div class="panel"><h3>Jobs</h3><table id="jobs"></table></div>
|
<div class="panel"><div class="row" style="justify-content:space-between"><h3 style="margin:0">Jobs</h3><button class="btn secondary" id="clear-jobs" style="padding:6px 12px;font-size:13px">Clear View</button></div><table id="jobs" style="margin-top:10px"></table></div>
|
||||||
</section>
|
</section>
|
||||||
<section id="settings" class="tab">
|
<section id="settings" class="tab">
|
||||||
<div class="panel row">
|
<div class="panel row">
|
||||||
@@ -93,8 +93,10 @@ a{color:#64b5f6}
|
|||||||
<section id="logs" class="tab">
|
<section id="logs" class="tab">
|
||||||
<div class="panel row">
|
<div class="panel row">
|
||||||
<label style="margin:0;display:flex;gap:6px;align-items:center"><input type="checkbox" id="autoscroll" checked style="width:auto">Auto scroll</label>
|
<label style="margin:0;display:flex;gap:6px;align-items:center"><input type="checkbox" id="autoscroll" checked style="width:auto">Auto scroll</label>
|
||||||
<button class="btn secondary" id="clear">Clear view</button>
|
<button class="btn secondary" id="copy-log">Copy Log</button>
|
||||||
|
<button class="btn secondary" id="clear">Clear View</button>
|
||||||
<a href="/api/logs/download">Download full log</a>
|
<a href="/api/logs/download">Download full log</a>
|
||||||
|
<span class="hint" id="copy-msg"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="log"></div>
|
<div id="log"></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -129,12 +131,12 @@ const FIELDS=[
|
|||||||
["sd_model","Image model","text","Hugging Face model id, e.g. stabilityai/sdxl-turbo or stabilityai/stable-diffusion-xl-base-1.0","g"],
|
["sd_model","Image model","text","Hugging Face model id, e.g. stabilityai/sdxl-turbo or stabilityai/stable-diffusion-xl-base-1.0","g"],
|
||||||
["sd_steps","Image steps","number","4 for turbo models, 20 to 30 for standard models","g"],
|
["sd_steps","Image steps","number","4 for turbo models, 20 to 30 for standard models","g"],
|
||||||
["sd_guidance","Image guidance","number","0 for turbo models, 7 for standard models","g"],
|
["sd_guidance","Image guidance","number","0 for turbo models, 7 for standard models","g"],
|
||||||
["sd_width","Image width","number","512 suits turbo. Larger is slower","g"],
|
["sd_width","Image width","number","768 avoids duplicated subjects. Larger is slower","g"],
|
||||||
["sd_height","Image height","number","896 suits turbo vertical","g"],
|
["sd_height","Image height","number","1344 is the correct vertical ratio for SDXL","g"],
|
||||||
["tts_engine","Narration engine","select:auto=Auto|say=macOS say|espeak=espeak-ng|piper=Piper","Auto picks the best available for this machine","g"],
|
["tts_engine","Narration engine","select:auto=Auto|piper=Piper (natural)|say=macOS say|espeak=espeak-ng","Auto uses Piper when a voice file is present, otherwise the system voice","g"],
|
||||||
["tts_voice","Narration voice","text","macOS: Samantha, Alex, Daniel. espeak: en-us","g"],
|
["tts_voice","Narration voice","text","macOS: Samantha, Alex, Daniel. espeak: en-us","g"],
|
||||||
["tts_rate","Narration speed","number","macOS words per minute (170). espeak uses a similar scale","g"],
|
["tts_rate","Narration speed","number","macOS words per minute (170). espeak uses a similar scale","g"],
|
||||||
["piper_model","Piper voice file","wide","Full path to a .onnx voice, only needed for Piper","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"],
|
["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"],
|
["veo_model","Veo model","text","e.g. veo-3.1-fast-generate-preview","g"],
|
||||||
["","Text and shared options","header","",""],
|
["","Text and shared options","header","",""],
|
||||||
@@ -258,6 +260,18 @@ await api("/auth/unlink",{method:"POST"});await loadSettings();refresh()}
|
|||||||
else{window.location.href="/auth/start"}};
|
else{window.location.href="/auth/start"}};
|
||||||
$("#save").onclick=saveSettings;
|
$("#save").onclick=saveSettings;
|
||||||
$("#clear").onclick=()=>{$("#log").innerHTML=""};
|
$("#clear").onclick=()=>{$("#log").innerHTML=""};
|
||||||
|
$("#copy-log").onclick=async()=>{
|
||||||
|
const text=$("#log").innerText.trim();
|
||||||
|
if(!text){$("#copy-msg").textContent="Nothing to copy";return}
|
||||||
|
try{await navigator.clipboard.writeText(text);$("#copy-msg").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");$("#copy-msg").textContent="Copied"}catch(err){$("#copy-msg").textContent="Could not copy"}
|
||||||
|
ta.remove()}
|
||||||
|
setTimeout(()=>{$("#copy-msg").textContent=""},3000)};
|
||||||
|
$("#clear-jobs").onclick=async()=>{
|
||||||
|
if(!confirm("Clear the job list? Videos on disk and on YouTube are not touched."))return;
|
||||||
|
try{await api("/api/history/clear",{method:"POST"});refresh()}catch(e){alert(e.message)}};
|
||||||
$("#preview").onclick=async()=>{
|
$("#preview").onclick=async()=>{
|
||||||
$("#preview-msg").textContent="Loading...";
|
$("#preview-msg").textContent="Loading...";
|
||||||
try{
|
try{
|
||||||
|
|||||||
Reference in New Issue
Block a user