Compare commits
10
Commits
c28f3bd86a
...
e4249e6742
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4249e6742 | ||
|
|
576979d4f9 | ||
|
|
4c4e7eb8a8 | ||
|
|
df5bcbb185 | ||
|
|
fcd9d77a4d | ||
|
|
0c52b95fd8 | ||
|
|
037e3cecaa | ||
|
|
287257c07f | ||
|
|
d2342e1bd0 | ||
|
|
7f26c406c7 |
No files matched your search
@@ -17,6 +17,8 @@ from zoneinfo import ZoneInfo
|
||||
import requests
|
||||
from fastapi import FastAPI, Request
|
||||
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.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
@@ -79,6 +81,10 @@ GLOBAL_DEFAULTS = {
|
||||
"aspect_ratio": "9:16",
|
||||
"target_seconds": 60,
|
||||
"clip_seconds": 8,
|
||||
"scene_timing": "narration",
|
||||
"low_memory": True,
|
||||
"free_models_between_stages": True,
|
||||
"cpu_threads": 0,
|
||||
"error_cooldown_minutes": 5,
|
||||
"work_hours_enabled": False,
|
||||
"work_days": "mon,tue,wed,thu,fri,sat,sun",
|
||||
@@ -298,7 +304,19 @@ def published_root(s):
|
||||
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):
|
||||
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:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
root = validate_storage(s["storage_dir"])
|
||||
free = free_gb(root)
|
||||
@@ -683,6 +710,49 @@ def record_slot(pid, iso):
|
||||
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():
|
||||
with state_lock:
|
||||
st = read_json(STATE_FILE, {})
|
||||
@@ -716,7 +786,14 @@ def load_creds(pid):
|
||||
return None
|
||||
creds = Credentials.from_authorized_user_file(str(tf), SCOPES)
|
||||
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")
|
||||
return creds
|
||||
|
||||
@@ -819,14 +896,22 @@ def fetch_trending(s, query=None):
|
||||
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:
|
||||
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()
|
||||
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:
|
||||
logger.debug("Could not list Ollama models: %s", e)
|
||||
return []
|
||||
return _tags_cache["models"]
|
||||
|
||||
|
||||
_caps_cache = {}
|
||||
@@ -836,7 +921,7 @@ def ollama_caps(s, model):
|
||||
if model in _caps_cache:
|
||||
return _caps_cache[model]
|
||||
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:
|
||||
return None
|
||||
caps = set(r.json().get("capabilities") or [])
|
||||
@@ -895,6 +980,21 @@ def ollama_pull(s, model):
|
||||
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):
|
||||
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")
|
||||
@@ -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")
|
||||
|
||||
|
||||
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"
|
||||
model = model or s["ollama_model"]
|
||||
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,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"keep_alive": "5m",
|
||||
"keep_alive": keep_alive,
|
||||
"options": {"temperature": temperature, "num_predict": num_predict, "num_ctx": 8192},
|
||||
}
|
||||
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"
|
||||
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:
|
||||
Title: {src['title']}
|
||||
Channel: {src['channel']}
|
||||
Description: {src['description']}
|
||||
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.
|
||||
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.
|
||||
@@ -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:
|
||||
"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.
|
||||
|
||||
@@ -1017,6 +1127,8 @@ Return JSON only in this shape:
|
||||
raise ValueError(f"expected {n} scenes, got {len(scenes)}")
|
||||
if not str(data.get("title", "")).strip():
|
||||
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["title"] = str(data["title"])
|
||||
data["style"] = str(data.get("style", ""))
|
||||
@@ -1346,10 +1458,10 @@ def kids_vision_check(s, clip_path):
|
||||
problems = []
|
||||
unclear = []
|
||||
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):
|
||||
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:
|
||||
return a
|
||||
return None
|
||||
@@ -1397,10 +1509,21 @@ def load_pipeline(s):
|
||||
device, dtype = "cuda", torch.float16
|
||||
else:
|
||||
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)
|
||||
pipe = AutoPipelineForText2Image.from_pretrained(model, torch_dtype=dtype, variant="fp16" if dtype == torch.float16 else None)
|
||||
pipe = pipe.to(device)
|
||||
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["obj"] = pipe
|
||||
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())
|
||||
|
||||
|
||||
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):
|
||||
engine = s["tts_engine"] if s["tts_engine"] in TTS_ENGINES else "auto"
|
||||
if engine != "auto":
|
||||
@@ -1454,7 +1611,7 @@ def system_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)
|
||||
try:
|
||||
if engine == "kokoro":
|
||||
@@ -1494,7 +1651,11 @@ def media_duration(path):
|
||||
|
||||
def render_scene(s, image, audio, out, aspect):
|
||||
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)
|
||||
vf = (
|
||||
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 = {}
|
||||
finalized = False
|
||||
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)
|
||||
remember_story(s["profile_id"], script)
|
||||
if engine == "local":
|
||||
unload_ollama(s, s["ollama_model"])
|
||||
compliance["engine"] = engine
|
||||
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})
|
||||
@@ -1747,62 +1914,98 @@ def run_job(s):
|
||||
raise
|
||||
stop_event.wait(10)
|
||||
|
||||
for i, scene in enumerate(script["scenes"], 1):
|
||||
set_stage(f"generating scene {i}/{n}", step=2 + i)
|
||||
path = job_dir / f"clip_{i:02d}.mp4"
|
||||
build_scene(i, scene, path)
|
||||
clips.append(path)
|
||||
anatomy_flags = []
|
||||
batch_pending = []
|
||||
inline = kids
|
||||
|
||||
anatomy_note = ""
|
||||
safety_hits = set()
|
||||
if kids:
|
||||
pending = list(range(1, n + 1))
|
||||
def judge(i, rnd):
|
||||
try:
|
||||
kids_vision_check(s, clips[i - 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):
|
||||
unload_pipeline()
|
||||
rejected = []
|
||||
results = {}
|
||||
for i in pending:
|
||||
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:
|
||||
kids_vision_check(s, clips[i - 1])
|
||||
vision_log.append({"clip": i, "round": rnd, "result": "passed"})
|
||||
except Cancelled:
|
||||
raise
|
||||
results[i] = judge(i, rnd)
|
||||
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]})
|
||||
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:
|
||||
raise RuntimeError(f"{e}. Frames were not verified, so nothing was uploaded")
|
||||
failed = [i for i, v in results.items() if v != "pass"]
|
||||
if not failed:
|
||||
break
|
||||
if rnd == 3:
|
||||
unsafe = [i for i in rejected if i in safety_hits]
|
||||
if unsafe:
|
||||
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}"
|
||||
for i in failed:
|
||||
settle(i, results[i])
|
||||
break
|
||||
for i in rejected:
|
||||
for i in failed:
|
||||
set_stage(f"regenerating scene {i}/{n}", step=2 + i)
|
||||
clips[i - 1].unlink(missing_ok=True)
|
||||
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:
|
||||
compliance["vision_checks"] = vision_log
|
||||
if anatomy_note:
|
||||
compliance["anatomy_note"] = anatomy_note
|
||||
prior = "; ".join(compliance.get("reviewer_notes", []))
|
||||
update_history(job_id, notes=(prior + "; " if prior else "") + anatomy_note)
|
||||
check_storage_alive(s)
|
||||
set_stage("stitching video", step=3 + n * 2)
|
||||
final = job_dir / "final.mp4"
|
||||
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 status["stage"] != "daily limit reached":
|
||||
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)
|
||||
stop_event.wait(60)
|
||||
continue
|
||||
@@ -1868,11 +2072,15 @@ def run_loop():
|
||||
except Cancelled:
|
||||
break
|
||||
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"]))
|
||||
set_stage(f"cooling down {mins} min after error")
|
||||
stop_event.wait(mins * 60)
|
||||
continue
|
||||
reset_progress()
|
||||
gap = int(s["minutes_between_videos"])
|
||||
if gap > 0 and not stop_event.is_set():
|
||||
set_stage(f"waiting {gap} min before next video")
|
||||
@@ -1884,7 +2092,23 @@ def run_loop():
|
||||
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()
|
||||
reset_stale_jobs()
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@@ -1920,8 +2144,7 @@ def api_status():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/settings")
|
||||
def get_settings():
|
||||
def build_settings():
|
||||
g = load_global()
|
||||
if g["gemini_api_key"]:
|
||||
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")
|
||||
async def post_settings(request: Request):
|
||||
body = await request.json()
|
||||
return await run_in_threadpool(apply_settings, body)
|
||||
|
||||
|
||||
def apply_settings(body):
|
||||
with settings_lock:
|
||||
g = load_global()
|
||||
old_storage = g["storage_dir"]
|
||||
|
||||
+10
-5
@@ -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.notes{color:var(--amber)}
|
||||
td .sub.err{color:var(--coral)}
|
||||
.st-published{--c:var(--sage)}.st-failed{--c:var(--coral)}.st-running{--c:var(--gold-text)}.st-review{--c:var(--violet)}.st-scheduled{--c:var(--sky)}.st-cancelled{--c:var(--muted)}
|
||||
.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}
|
||||
.empty{padding:36px 16px;text-align:center;color:var(--muted)}
|
||||
.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"],
|
||||
["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"],
|
||||
["","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",""],
|
||||
["ollama_url","Ollama URL","text","","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"],
|
||||
["aspect_ratio","Aspect ratio","select:9:16|16:9","","g"],
|
||||
["target_seconds","Target length (seconds)","select:30|45|60|90|120|180","Split into scenes of the length below","g"],
|
||||
["clip_seconds","Seconds per scene","select:5|6|8|10|12","8 gives 7 scenes in a 60 second Short","g"],
|
||||
["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"]
|
||||
];
|
||||
const DAYS=[["mon","Mon"],["tue","Tue"],["wed","Wed"],["thu","Thu"],["fri","Fri"],["sat","Sat"],["sun","Sun"]];
|
||||
const TIMES=[];for(let h=0;h<24;h++)for(const m of [0,30]){const v=String(h).padStart(2,"0")+":"+String(m).padStart(2,"0");const hr=h%12||12;TIMES.push([v,hr+":"+String(m).padStart(2,"0")+(h<12?" AM":" PM")])}
|
||||
const NUMERIC=new Set(["sd_steps","sd_guidance","sd_width","sd_height","trending_days","trending_count","videos_per_day","minutes_between_videos","target_seconds","clip_seconds","ollama_timeout","error_cooldown_minutes","tts_rate"]);
|
||||
const 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 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}
|
||||
@@ -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()}
|
||||
|
||||
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 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":""});
|
||||
const pct=Math.min(100,Math.round(s.step*100/s.steps));
|
||||
$("#pg-meta").textContent=`Step ${s.step} of ${s.steps}, ${pct}% done`+(s.elapsed?`, running for ${fmtDur(s.elapsed)}`:"");
|
||||
}else{pg.classList.remove("show");note.hidden=false;note.textContent=s.running?"Waiting for the next video to start.":"Press GO to start making videos for the active profile."}
|
||||
}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"}
|
||||
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):""};
|
||||
|
||||
Reference in new issue
Block a user