Compare commits

...
10 Commits
2 changed files with 296 additions and 59 deletions

No files matched your search

+286 -54
View File
@@ -17,6 +17,8 @@ from zoneinfo import ZoneInfo
import requests import requests
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from starlette.concurrency import run_in_threadpool
from google.auth.exceptions import RefreshError
from google.auth.transport.requests import Request as GoogleRequest from google.auth.transport.requests import Request as GoogleRequest
from google.oauth2.credentials import Credentials from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import Flow from google_auth_oauthlib.flow import Flow
@@ -79,6 +81,10 @@ GLOBAL_DEFAULTS = {
"aspect_ratio": "9:16", "aspect_ratio": "9:16",
"target_seconds": 60, "target_seconds": 60,
"clip_seconds": 8, "clip_seconds": 8,
"scene_timing": "narration",
"low_memory": True,
"free_models_between_stages": True,
"cpu_threads": 0,
"error_cooldown_minutes": 5, "error_cooldown_minutes": 5,
"work_hours_enabled": False, "work_hours_enabled": False,
"work_days": "mon,tue,wed,thu,fri,sat,sun", "work_days": "mon,tue,wed,thu,fri,sat,sun",
@@ -298,7 +304,19 @@ def published_root(s):
return resolve_storage(s["storage_dir"]) / "published" return resolve_storage(s["storage_dir"]) / "published"
def volume_ready(path):
parts = Path(path).resolve().parts
if len(parts) >= 3 and parts[1] == "Volumes":
mount = Path("/Volumes") / parts[2]
if not os.path.ismount(str(mount)):
return False, str(mount)
return True, ""
def writable(root): def writable(root):
ok, mount = volume_ready(root)
if not ok:
raise ValueError(f"The volume {mount} is not mounted. Connect the disk, or pick a different folder in Settings")
try: try:
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
probe = root / ".write_test" probe = root / ".write_test"
@@ -485,6 +503,15 @@ def sweep_temp(temp, max_age_hours=1):
logger.info("Cleaned %d old items from the temp folder", removed) logger.info("Cleaned %d old items from the temp folder", removed)
def check_storage_alive(s):
root = resolve_storage(s["storage_dir"])
ok, mount = volume_ready(root)
if not ok:
raise RuntimeError(f"The volume {mount} is no longer mounted, the disk may have unmounted. Nothing more was written")
if not root.is_dir():
raise RuntimeError(f"The storage folder {root} disappeared, the disk may have unmounted")
def prepare_storage(s): def prepare_storage(s):
root = validate_storage(s["storage_dir"]) root = validate_storage(s["storage_dir"])
free = free_gb(root) free = free_gb(root)
@@ -683,6 +710,49 @@ def record_slot(pid, iso):
save_state(st) save_state(st)
def recent_stories(pid):
return load_state().get("stories", {}).get(pid, [])
def remember_story(pid, script):
names = re.findall(r"\b([A-Z][a-z]{2,})\b", script.get("characters", ""))
stop = {"The", "His", "Her", "Their", "With", "And", "Who", "She", "They", "This", "That", "Mr", "Mrs", "Miss"}
names = [n for n in dict.fromkeys(names) if n not in stop][:6]
with state_lock:
st = load_state()
lst = st.setdefault("stories", {}).setdefault(pid, [])
lst.append({"title": script["title"], "names": names})
st["stories"][pid] = lst[-50:]
save_state(st)
def channel_titles(s):
try:
yt = youtube_client(s["profile_id"])
ch = yt.channels().list(part="contentDetails", mine=True).execute().get("items", [])
if not ch:
return []
uploads = ch[0]["contentDetails"]["relatedPlaylists"]["uploads"]
items = yt.playlistItems().list(part="snippet", playlistId=uploads, maxResults=50).execute().get("items", [])
titles = [re.sub(r"\s*#shorts\b", "", i["snippet"]["title"], flags=re.I).strip() for i in items]
return [x for x in titles if x]
except Exception as e:
logger.warning("Could not read existing channel titles: %s", e)
return []
def past_stories(s):
past = list(recent_stories(s["profile_id"]))
for title in s.get("_channel_titles", []):
names = re.findall(r"\b([A-Z][a-z]{2,})'s\b", title)
past.append({"title": title, "names": names})
return past
def norm_title(t):
return re.sub(r"[^a-z0-9 ]", "", str(t).lower()).strip()
def load_state(): def load_state():
with state_lock: with state_lock:
st = read_json(STATE_FILE, {}) st = read_json(STATE_FILE, {})
@@ -716,7 +786,14 @@ def load_creds(pid):
return None return None
creds = Credentials.from_authorized_user_file(str(tf), SCOPES) creds = Credentials.from_authorized_user_file(str(tf), SCOPES)
if creds.expired and creds.refresh_token: if creds.expired and creds.refresh_token:
creds.refresh(GoogleRequest()) try:
creds.refresh(GoogleRequest())
except RefreshError:
raise RuntimeError(
f"The YouTube sign in for profile '{load_profile(pid)['name']}' has expired. "
"Unlink and link the channel again in Settings. "
"Publishing the app in Google Cloud stops this happening every week"
) from None
tf.write_text(creds.to_json(), encoding="utf-8") tf.write_text(creds.to_json(), encoding="utf-8")
return creds return creds
@@ -819,14 +896,22 @@ def fetch_trending(s, query=None):
return kept return kept
def ollama_tags(s): _tags_cache = {"at": 0.0, "models": []}
def ollama_tags(s, max_age=60):
now = datetime.now().timestamp()
if _tags_cache["models"] and now - _tags_cache["at"] < max_age:
return _tags_cache["models"]
try: try:
r = requests.get(f"{s['ollama_url'].rstrip('/')}/api/tags", timeout=10) r = requests.get(f"{s['ollama_url'].rstrip('/')}/api/tags", timeout=5)
r.raise_for_status() r.raise_for_status()
return sorted(m["name"] for m in r.json().get("models", []) if m.get("name")) models = sorted(m["name"] for m in r.json().get("models", []) if m.get("name"))
_tags_cache.update({"at": now, "models": models})
return models
except Exception as e: except Exception as e:
logger.debug("Could not list Ollama models: %s", e) logger.debug("Could not list Ollama models: %s", e)
return [] return _tags_cache["models"]
_caps_cache = {} _caps_cache = {}
@@ -836,7 +921,7 @@ def ollama_caps(s, model):
if model in _caps_cache: if model in _caps_cache:
return _caps_cache[model] return _caps_cache[model]
try: try:
r = requests.post(f"{s['ollama_url'].rstrip('/')}/api/show", json={"model": model}, timeout=10) r = requests.post(f"{s['ollama_url'].rstrip('/')}/api/show", json={"model": model}, timeout=5)
if r.status_code != 200: if r.status_code != 200:
return None return None
caps = set(r.json().get("capabilities") or []) caps = set(r.json().get("capabilities") or [])
@@ -895,6 +980,21 @@ def ollama_pull(s, model):
return True return True
def unload_ollama(s, model):
model = (model or "").strip()
if not model or not s.get("free_models_between_stages", True):
return
try:
requests.post(
f"{s['ollama_url'].rstrip('/')}/api/generate",
json={"model": model, "prompt": "", "keep_alive": 0, "stream": False},
timeout=20,
)
logger.debug("Asked Ollama to release %s", model)
except Exception as e:
logger.debug("Could not release %s: %s", model, e)
def ensure_local_deps(s): def ensure_local_deps(s):
if not ensure_module(s, "torch", "torch") or not ensure_module(s, "diffusers", "diffusers accelerate safetensors transformers".split()[0]): if not ensure_module(s, "torch", "torch") or not ensure_module(s, "diffusers", "diffusers accelerate safetensors transformers".split()[0]):
raise RuntimeError("Could not install the image packages. Run ./install-local.sh") raise RuntimeError("Could not install the image packages. Run ./install-local.sh")
@@ -930,7 +1030,7 @@ def ensure_models(s, engine):
raise RuntimeError(f"'{vm}' is a text model and cannot check images. Pick a vision model such as qwen2.5vl:7b in Settings") raise RuntimeError(f"'{vm}' is a text model and cannot check images. Pick a vision model such as qwen2.5vl:7b in Settings")
def ollama_call(s, prompt, temperature=0.9, model=None, images=None, num_predict=2048, as_json=True): def ollama_call(s, prompt, temperature=0.9, model=None, images=None, num_predict=2048, as_json=True, keep_alive="5m"):
url = f"{s['ollama_url'].rstrip('/')}/api/generate" url = f"{s['ollama_url'].rstrip('/')}/api/generate"
model = model or s["ollama_model"] model = model or s["ollama_model"]
timeout = max(30, int(s["ollama_timeout"])) timeout = max(30, int(s["ollama_timeout"]))
@@ -938,7 +1038,7 @@ def ollama_call(s, prompt, temperature=0.9, model=None, images=None, num_predict
"model": model, "model": model,
"prompt": prompt, "prompt": prompt,
"stream": False, "stream": False,
"keep_alive": "5m", "keep_alive": keep_alive,
"options": {"temperature": temperature, "num_predict": num_predict, "num_ctx": 8192}, "options": {"temperature": temperature, "num_predict": num_predict, "num_ctx": 8192},
} }
if as_json: if as_json:
@@ -982,13 +1082,23 @@ def write_script(s, src, feedback=None):
"A previous draft was rejected for these problems. Do not repeat them:\n" + "\n".join(f"- {f}" for f in feedback) + "\n" "A previous draft was rejected for these problems. Do not repeat them:\n" + "\n".join(f"- {f}" for f in feedback) + "\n"
if feedback else "" if feedback else ""
) )
past = past_stories(s)
used_titles = list(dict.fromkeys(x["title"] for x in past))[-40:]
used_names = sorted({n for x in past for n in x.get("names", [])})
avoid_block = ""
if used_titles or used_names:
avoid_block = "This channel has already published stories. Make this one feel new.\n"
if used_titles:
avoid_block += "Do not reuse or closely copy any of these titles: " + "; ".join(used_titles) + "\n"
if used_names:
avoid_block += "Do not use any of these character names: " + ", ".join(used_names) + ". Invent fresh names.\n"
prompt = f"""You are a short-form video writer. This video is currently popular on YouTube: prompt = f"""You are a short-form video writer. This video is currently popular on YouTube:
Title: {src['title']} Title: {src['title']}
Channel: {src['channel']} Channel: {src['channel']}
Description: {src['description']} Description: {src['description']}
Tags: {', '.join(src['tags'])} Tags: {', '.join(src['tags'])}
{niche_block}{kids_block}{feedback_block} {niche_block}{kids_block}{feedback_block}{avoid_block}
Identify the underlying topic and why it appeals to viewers. Then write a completely ORIGINAL {n * clip}-second YouTube Short in English. Identify the underlying topic and why it appeals to viewers. Then write a completely ORIGINAL {n * clip}-second YouTube Short in English.
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.
@@ -998,7 +1108,7 @@ Every character is a cartoon: a cartoon animal, a cartoon creature, or a simple
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 (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. "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 {max(4, words - 5)} to {words} words, so it fills most of the scene. Write real words only, never stretched sounds like Mmm, Ahhh, Ooooh, or Shhh, because the narrator reads those letter by letter.
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.
@@ -1017,6 +1127,8 @@ Return JSON only in this shape:
raise ValueError(f"expected {n} scenes, got {len(scenes)}") raise ValueError(f"expected {n} scenes, got {len(scenes)}")
if not str(data.get("title", "")).strip(): if not str(data.get("title", "")).strip():
raise ValueError("missing title") raise ValueError("missing title")
if norm_title(data["title"]) in {norm_title(x["title"]) for x in past_stories(s)}:
raise ValueError(f"title '{data['title']}' was already used on this channel")
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", ""))
@@ -1346,10 +1458,10 @@ def kids_vision_check(s, clip_path):
problems = [] problems = []
unclear = [] unclear = []
try: try:
desc = ollama_call(s, "Describe this image in one short sentence.", temperature=0.1, model=model, images=images, num_predict=80, as_json=False).strip() desc = ollama_call(s, "Describe this image in one short sentence.", temperature=0.1, model=model, images=images, num_predict=80, as_json=False, keep_alive="30s").strip()
def ask(q): def ask(q):
for _ in range(2): for _ in range(2):
a = yes_no(ollama_call(s, q, temperature=0.0, model=model, images=images, num_predict=10, as_json=False)) a = yes_no(ollama_call(s, q, temperature=0.0, model=model, images=images, num_predict=10, as_json=False, keep_alive="30s"))
if a is not None: if a is not None:
return a return a
return None return None
@@ -1397,10 +1509,21 @@ def load_pipeline(s):
device, dtype = "cuda", torch.float16 device, dtype = "cuda", torch.float16
else: else:
device, dtype = "cpu", torch.float32 device, dtype = "cpu", torch.float32
threads = int(s.get("cpu_threads", 0) or 0)
if threads > 0:
torch.set_num_threads(threads)
logger.info("Limiting image generation to %d CPU threads", threads)
logger.info("Loading image model %s on %s (first run downloads several GB)", model, device) logger.info("Loading image model %s on %s (first run downloads several GB)", model, device)
pipe = AutoPipelineForText2Image.from_pretrained(model, torch_dtype=dtype, variant="fp16" if dtype == torch.float16 else None) pipe = AutoPipelineForText2Image.from_pretrained(model, torch_dtype=dtype, variant="fp16" if dtype == torch.float16 else None)
pipe = pipe.to(device) pipe = pipe.to(device)
pipe.set_progress_bar_config(disable=True) pipe.set_progress_bar_config(disable=True)
if s.get("low_memory", True):
for enable in ("enable_attention_slicing", "enable_vae_slicing", "enable_vae_tiling"):
try:
getattr(pipe, enable)()
except Exception:
pass
logger.info("Low memory mode is on for image generation")
pipeline["id"] = model pipeline["id"] = model
pipeline["obj"] = pipe pipeline["obj"] = pipe
logger.info("Image model ready") logger.info("Image model ready")
@@ -1425,6 +1548,40 @@ def generate_image(s, prompt, path):
logger.info("Generated %s in %.1fs", path.name, (datetime.now() - started).total_seconds()) logger.info("Generated %s in %.1fs", path.name, (datetime.now() - started).total_seconds())
SPEECH_FIXES = [
(r"\bm{2,}\b", "Yum"),
(r"\bmhm+\b", "Mm hmm"),
(r"\bhm{2,}\b", "Hmm"),
(r"\bo{2,}h*\b", "Ooh"),
(r"\ba{2,}h+\b", "Ah"),
(r"\bah{2,}\b", "Ah"),
(r"\bsh{2,}\b", "Shh"),
(r"\bw+o{2,}h*\b", "Whoa"),
(r"\bwh?e{2,}\b", "Whee"),
(r"\byay+\b", "Yay"),
(r"\bya{2,}y*\b", "Yay"),
(r"\bye{2,}h*\b", "Yeah"),
(r"\bhur+a+h*\b", "Hooray"),
(r"\bz{2,}\b", "Zzz"),
(r"\bbr{2,}\b", "Brr"),
(r"\bgr{2,}\b", "Grr"),
(r"\bha{2,}\b", "Ha ha"),
(r"\bhe{2,}\b", "Hee hee"),
(r"\bwo{2,}w+\b", "Wow"),
]
def speakable(text):
out = text
for pattern, replacement in SPEECH_FIXES:
out = re.sub(pattern, replacement, out, flags=re.I)
out = re.sub(r"([!?.,])\1{1,}", r"\1", out)
out = re.sub(r"\s{2,}", " ", out).strip()
if out != text:
logger.debug("Narration adjusted for speech: %r to %r", text, out)
return out
def pick_tts(s): def pick_tts(s):
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"
if engine != "auto": if engine != "auto":
@@ -1454,7 +1611,7 @@ def system_speak(s, text, out_wav):
def tts_speak(s, text, out_wav): def tts_speak(s, text, out_wav):
text = text.strip() or "..." text = speakable(text.strip()) or "..."
engine = pick_tts(s) engine = pick_tts(s)
try: try:
if engine == "kokoro": if engine == "kokoro":
@@ -1494,7 +1651,11 @@ def media_duration(path):
def render_scene(s, image, audio, out, aspect): def render_scene(s, image, audio, out, aspect):
w, h = (1080, 1920) if aspect == "9:16" else (1920, 1080) w, h = (1080, 1920) if aspect == "9:16" else (1920, 1080)
dur = max(float(s["clip_seconds"]), media_duration(audio) + 0.6) spoken = media_duration(audio)
if s.get("scene_timing", "narration") == "fixed":
dur = max(float(s["clip_seconds"]), spoken + 0.6)
else:
dur = max(2.5, spoken + 0.8)
frames = int(dur * 30) frames = int(dur * 30)
vf = ( vf = (
f"[0:v]scale={w * 2}:{h * 2}:force_original_aspect_ratio=increase,crop={w * 2}:{h * 2}," f"[0:v]scale={w * 2}:{h * 2}:force_original_aspect_ratio=increase,crop={w * 2}:{h * 2},"
@@ -1718,7 +1879,13 @@ def run_job(s):
compliance = {} compliance = {}
finalized = False finalized = False
try: try:
s["_channel_titles"] = channel_titles(s)
if s["_channel_titles"]:
logger.info("Loaded %d existing channel titles to avoid repeats", len(s["_channel_titles"]))
script, tags, compliance = produce_script(s, src) script, tags, compliance = produce_script(s, src)
remember_story(s["profile_id"], script)
if engine == "local":
unload_ollama(s, s["ollama_model"])
compliance["engine"] = engine compliance["engine"] = engine
update_history(job_id, title=script["title"], notes="; ".join(compliance.get("reviewer_notes", []))[:400]) update_history(job_id, title=script["title"], notes="; ".join(compliance.get("reviewer_notes", []))[:400])
write_json(job_dir / "script.json", {"profile": s["name"], "source": src, "script": script, "tags": tags}) write_json(job_dir / "script.json", {"profile": s["name"], "source": src, "script": script, "tags": tags})
@@ -1747,62 +1914,98 @@ def run_job(s):
raise raise
stop_event.wait(10) stop_event.wait(10)
for i, scene in enumerate(script["scenes"], 1): anatomy_flags = []
set_stage(f"generating scene {i}/{n}", step=2 + i) batch_pending = []
path = job_dir / f"clip_{i:02d}.mp4" inline = kids
build_scene(i, scene, path)
clips.append(path)
anatomy_note = "" def judge(i, rnd):
safety_hits = set() try:
if kids: kids_vision_check(s, clips[i - 1])
pending = list(range(1, n + 1)) vision_log.append({"clip": i, "round": rnd, "result": "passed"})
return "pass"
except Cancelled:
raise
except VisionUnavailable as e:
if s["kids_manual_review"]:
logger.warning("Scene %d: %s. Continuing, check this video manually before publishing", i, e)
vision_log.append({"clip": i, "round": rnd, "result": f"skipped: {e}"[:300]})
return "pass"
raise
except VisionReject as e:
logger.warning("Scene %d attempt %d rejected: %s", i, rnd, e)
vision_log.append({"clip": i, "round": rnd, "result": str(e)[:300]})
return "unsafe" if e.safety else "anatomy"
def settle(i, verdict):
if verdict == "unsafe":
raise RuntimeError(f"Scene {i} still failed the safety check after 3 attempts")
if verdict == "anatomy":
logger.warning("Scene %d may still have an anatomy glitch after 3 attempts, keeping it. Check it before publishing", i)
anatomy_flags.append(i)
for i, scene in enumerate(script["scenes"], 1):
check_storage_alive(s)
path = job_dir / f"clip_{i:02d}.mp4"
clips.append(path)
verdict = "pass"
for attempt in range(1, 4):
set_stage(f"generating scene {i}/{n}" + (f" (attempt {attempt})" if attempt > 1 else ""), step=(1 + 2 * i) if inline else (2 + i))
path.unlink(missing_ok=True)
build_scene(i, scene, path)
if not inline:
if kids:
batch_pending.append(i)
verdict = "pass"
break
set_stage(f"checking scene {i}/{n}" + (f" (attempt {attempt})" if attempt > 1 else ""), step=2 + 2 * i)
try:
verdict = judge(i, attempt)
except VisionUnavailable as e:
logger.warning("Checking scenes right after drawing failed (%s). Switching to checking after all scenes are drawn", e)
inline = False
batch_pending.append(i)
verdict = "pass"
break
if verdict == "pass":
break
if inline:
settle(i, verdict)
if batch_pending:
pending = batch_pending
for rnd in range(1, 4): for rnd in range(1, 4):
unload_pipeline() unload_pipeline()
rejected = [] results = {}
for i in pending: for i in pending:
check() check()
set_stage(f"vision check scene {i}/{n}", step=2 + n + i) set_stage(f"checking scene {i}/{n}", step=2 + n + i)
try: try:
kids_vision_check(s, clips[i - 1]) results[i] = judge(i, rnd)
vision_log.append({"clip": i, "round": rnd, "result": "passed"})
except Cancelled:
raise
except VisionUnavailable as e: except VisionUnavailable as e:
if s["kids_manual_review"]: raise RuntimeError(f"{e}. Frames were not verified, so nothing was uploaded")
logger.warning("Scene %d: %s. Continuing, check this video manually before publishing", i, e) failed = [i for i, v in results.items() if v != "pass"]
vision_log.append({"clip": i, "round": rnd, "result": f"skipped: {e}"[:300]}) if not failed:
else:
raise RuntimeError(f"{e}. Frames were not verified, so nothing was uploaded")
except Exception as e:
logger.warning("Scene %d rejected (round %d): %s", i, rnd, e)
vision_log.append({"clip": i, "round": rnd, "result": str(e)[:300]})
rejected.append(i)
if getattr(e, "safety", True):
safety_hits.add(i)
else:
safety_hits.discard(i)
if not rejected:
break break
if rnd == 3: if rnd == 3:
unsafe = [i for i in rejected if i in safety_hits] for i in failed:
if unsafe: settle(i, results[i])
raise RuntimeError(f"Scenes {unsafe} still failed the safety check after 3 rounds")
flagged = ", ".join(str(i) for i in rejected)
logger.warning("Scenes %s may still have anatomy glitches after 3 redraws, keeping them. Check them before publishing", flagged)
anatomy_note = f"Possible anatomy glitches kept in scenes {flagged}"
break break
for i in rejected: for i in failed:
set_stage(f"regenerating scene {i}/{n}", step=2 + i) set_stage(f"regenerating scene {i}/{n}", step=2 + i)
clips[i - 1].unlink(missing_ok=True) clips[i - 1].unlink(missing_ok=True)
build_scene(i, script["scenes"][i - 1], clips[i - 1]) build_scene(i, script["scenes"][i - 1], clips[i - 1])
pending = rejected pending = failed
if kids:
unload_ollama(s, s["vision_model"])
anatomy_note = f"Possible anatomy glitches kept in scenes {', '.join(str(i) for i in anatomy_flags)}" if anatomy_flags else ""
if kids: if kids:
compliance["vision_checks"] = vision_log compliance["vision_checks"] = vision_log
if anatomy_note: if anatomy_note:
compliance["anatomy_note"] = anatomy_note compliance["anatomy_note"] = anatomy_note
prior = "; ".join(compliance.get("reviewer_notes", [])) prior = "; ".join(compliance.get("reviewer_notes", []))
update_history(job_id, notes=(prior + "; " if prior else "") + anatomy_note) update_history(job_id, notes=(prior + "; " if prior else "") + anatomy_note)
check_storage_alive(s)
set_stage("stitching video", step=3 + n * 2) set_stage("stitching video", step=3 + n * 2)
final = job_dir / "final.mp4" final = job_dir / "final.mp4"
concat_clips(clips, final, s["aspect_ratio"]) concat_clips(clips, final, s["aspect_ratio"])
@@ -1860,6 +2063,7 @@ def run_loop():
if limit > 0 and published_today(s["profile_id"]) >= limit: if limit > 0 and published_today(s["profile_id"]) >= limit:
if status["stage"] != "daily limit reached": if status["stage"] != "daily limit reached":
logger.info("Profile '%s' reached its daily limit of %d, waiting for tomorrow", s["name"], limit) logger.info("Profile '%s' reached its daily limit of %d, waiting for tomorrow", s["name"], limit)
reset_progress()
set_stage("daily limit reached", log=False) set_stage("daily limit reached", log=False)
stop_event.wait(60) stop_event.wait(60)
continue continue
@@ -1868,11 +2072,15 @@ def run_loop():
except Cancelled: except Cancelled:
break break
except Exception as e: except Exception as e:
logger.exception("Job failed: %s", e) if "has expired" in str(e) or "not mounted" in str(e) or "unmounted" in str(e):
logger.error("%s", e)
else:
logger.exception("Job failed: %s", e)
mins = max(1, int(s["error_cooldown_minutes"])) mins = max(1, int(s["error_cooldown_minutes"]))
set_stage(f"cooling down {mins} min after error") set_stage(f"cooling down {mins} min after error")
stop_event.wait(mins * 60) stop_event.wait(mins * 60)
continue continue
reset_progress()
gap = int(s["minutes_between_videos"]) gap = int(s["minutes_between_videos"])
if gap > 0 and not stop_event.is_set(): if gap > 0 and not stop_event.is_set():
set_stage(f"waiting {gap} min before next video") set_stage(f"waiting {gap} min before next video")
@@ -1884,7 +2092,23 @@ def run_loop():
logger.info("Autopilot stopped") logger.info("Autopilot stopped")
def reset_stale_jobs():
with state_lock:
st = load_state()
stale = [h for h in st["history"] if h.get("status") == "running"]
if not stale:
return
now = int(datetime.now().timestamp())
for h in stale:
h["status"] = "interrupted"
h["error"] = "The app stopped before this job finished"
h.setdefault("finished_ts", h.get("started_ts", now))
save_state(st)
logger.info("Marked %d unfinished job(s) from a previous run as interrupted", len(stale))
migrate() migrate()
reset_stale_jobs()
app = FastAPI() app = FastAPI()
@@ -1920,8 +2144,7 @@ def api_status():
} }
@app.get("/api/settings") def build_settings():
def get_settings():
g = load_global() g = load_global()
if g["gemini_api_key"]: if g["gemini_api_key"]:
g["gemini_api_key"] = MASK g["gemini_api_key"] = MASK
@@ -1940,9 +2163,18 @@ def get_settings():
} }
@app.get("/api/settings")
async def get_settings():
return await run_in_threadpool(build_settings)
@app.post("/api/settings") @app.post("/api/settings")
async def post_settings(request: Request): async def post_settings(request: Request):
body = await request.json() body = await request.json()
return await run_in_threadpool(apply_settings, body)
def apply_settings(body):
with settings_lock: with settings_lock:
g = load_global() g = load_global()
old_storage = g["storage_dir"] old_storage = g["storage_dir"]
+10 -5
View File
@@ -116,7 +116,7 @@ td.title{font-weight:700}
td .sub{font-size:12.5px;color:var(--muted);margin-top:6px;overflow-wrap:anywhere} td .sub{font-size:12.5px;color:var(--muted);margin-top:6px;overflow-wrap:anywhere}
td .sub.notes{color:var(--amber)} td .sub.notes{color:var(--amber)}
td .sub.err{color:var(--coral)} 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)} .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)}
.links{display:flex;gap:6px} .links{display:flex;gap:6px}
.empty{padding:36px 16px;text-align:center;color:var(--muted)} .empty{padding:36px 16px;text-align:center;color:var(--muted)}
.source a{color:var(--text);text-decoration-color:var(--line)} .source a{color:var(--text);text-decoration-color:var(--line)}
@@ -319,6 +319,10 @@ const FIELDS=[
["piper_model","Piper voice file","wide","Blank uses the first voice found in the voices folder under Models","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"],
["","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"],
["","Writing and timing","header","Shared by all profiles",""], ["","Writing and timing","header","Shared by all profiles",""],
["ollama_url","Ollama URL","text","","g"], ["ollama_url","Ollama URL","text","","g"],
["ollama_model","Ollama model","model:text","Writes scripts and tags. Models not installed yet are downloaded automatically","g"], ["ollama_model","Ollama model","model:text","Writes scripts and tags. Models not installed yet are downloaded automatically","g"],
@@ -326,12 +330,13 @@ const FIELDS=[
["ollama_timeout","Ollama timeout (seconds)","select:120|300|600|900","Max wait per Ollama request before retrying","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"], ["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"], ["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"], ["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"],
["error_cooldown_minutes","Wait after error (minutes)","select:1|5|10|15|30|60","","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 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 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 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"]);
const $=s=>document.querySelector(s); const $=s=>document.querySelector(s);
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); 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} 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}
@@ -429,7 +434,7 @@ 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()} 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"}; const STATUS_TEXT={review:"Private, needs review",published:"Published",failed:"Failed",running:"Running",cancelled:"Cancelled",scheduled:"Scheduled",interrupted:"Interrupted"};
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 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"})} 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"})}
@@ -451,7 +456,7 @@ const segs=$("#pg-segs");if(segs.childElementCount!==s.steps){segs.innerHTML="<i
[...segs.children].forEach((el,i)=>{el.className=i<s.step-1?"on":i===s.step-1?"now":""}); [...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)); 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)}`:""); $("#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."} }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."}
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"} 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); 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 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):""};