diff --git a/ltx_director.py b/ltx_director.py index cae4653..61d20f0 100644 --- a/ltx_director.py +++ b/ltx_director.py @@ -480,6 +480,30 @@ def _analysis_text_is_usable(text: str) -> bool: if len(text.split()) < 6: return False return True + + +def _compress_analysis_image_b64(b64_payload: str, max_dim: int = 768, quality: int = 82) -> str: + """Shrink analysis images so multimodal providers do not burn their full context on pixels.""" + try: + raw = base64.b64decode(_normalise_b64_payload(b64_payload)) + with Image.open(_io.BytesIO(raw)) as img: + img = img.convert("RGB") + w, h = img.size + if max(w, h) > max_dim: + scale = max_dim / float(max(w, h)) + img = img.resize((max(1, int(round(w * scale))), max(1, int(round(h * scale)))), Image.LANCZOS) + out = _io.BytesIO() + img.save(out, format="JPEG", quality=quality, optimize=True) + return base64.b64encode(out.getvalue()).decode("ascii") + except Exception: + return _normalise_b64_payload(b64_payload) + + +def _prepare_analysis_images(cleaned_b64_list: list[str], max_dim: int = 768, quality: int = 82) -> list[str]: + return [ + _compress_analysis_image_b64(b64, max_dim=max_dim, quality=quality) + for b64 in cleaned_b64_list + ] def _resolve_provider(data): @@ -511,13 +535,14 @@ async def analyze_character_endpoint(request): if "," in b64: b64 = b64.split(",", 1)[1] cleaned_b64_list.append(b64) - if not cleaned_b64_list: - return web.json_response({"status": "error", "message": "No valid base64 images decoded."}) - - if provider in ("lmstudio", "custom") and not model_name: - return web.json_response({ - "status": "error", - "message": f"No model name set for {provider}. Open the gear menu and enter your loaded model's name.", + if not cleaned_b64_list: + return web.json_response({"status": "error", "message": "No valid base64 images decoded."}) + analysis_images = _prepare_analysis_images(cleaned_b64_list, max_dim=768, quality=82) + + if provider in ("lmstudio", "custom") and not model_name: + return web.json_response({ + "status": "error", + "message": f"No model name set for {provider}. Open the gear menu and enter your loaded model's name.", }) log.info("[LTXDirector] Analyzing Character %d via %s (%s, model '%s')...", @@ -526,10 +551,11 @@ async def analyze_character_endpoint(request): try: async with aiohttp.ClientSession() as session: if provider == "ollama": + generated_text = "" payload = { "model": model_name, "prompt": _ANALYZE_PROMPT, - "images": cleaned_b64_list, + "images": analysis_images, "stream": False, "keep_alive": 0, "options": { @@ -540,9 +566,20 @@ async def analyze_character_endpoint(request): async with session.post(f"{base_url}/api/generate", json=payload, timeout=300) as response: if response.status != 200: err_txt = await response.text() - return web.json_response({"status": "error", "message": f"Ollama HTTP {response.status}: {err_txt}"}) - resp_json = await response.json() - generated_text = _extract_ollama_generated_text(resp_json) + if response.status == 400 and "exceeds the available context size" in err_txt: + analysis_images = _prepare_analysis_images(cleaned_b64_list, max_dim=512, quality=70) + else: + return web.json_response({"status": "error", "message": f"Ollama HTTP {response.status}: {err_txt}"}) + else: + resp_json = await response.json() + generated_text = _extract_ollama_generated_text(resp_json) + if not generated_text: + async with session.post(f"{base_url}/api/generate", json={**payload, "images": analysis_images}, timeout=300) as response: + if response.status != 200: + err_txt = await response.text() + return web.json_response({"status": "error", "message": f"Ollama HTTP {response.status}: {err_txt}"}) + resp_json = await response.json() + generated_text = _extract_ollama_generated_text(resp_json) # Some Ollama model variants return an empty or truncated response from # `/api/generate` even though the same request succeeds via the chat endpoint. @@ -552,7 +589,7 @@ async def analyze_character_endpoint(request): "messages": [{ "role": "user", "content": _ANALYZE_PROMPT, - "images": cleaned_b64_list, + "images": analysis_images, }], "stream": False, "keep_alive": 0, @@ -565,11 +602,32 @@ async def analyze_character_endpoint(request): if response.status == 200: resp_json = await response.json() generated_text = _extract_ollama_generated_text(resp_json) + else: + err_txt = await response.text() + if response.status == 400 and "exceeds the available context size" in err_txt: + smaller_images = _prepare_analysis_images(cleaned_b64_list, max_dim=384, quality=60) + retry_payload = { + **chat_payload, + "messages": [{ + "role": "user", + "content": _ANALYZE_PROMPT, + "images": smaller_images, + }], + } + async with session.post(f"{base_url}/api/chat", json=retry_payload, timeout=300) as retry_response: + if retry_response.status == 200: + resp_json = await retry_response.json() + generated_text = _extract_ollama_generated_text(resp_json) + else: + err_txt = await retry_response.text() + return web.json_response({"status": "error", "message": f"Ollama HTTP {retry_response.status}: {err_txt}"}) + else: + return web.json_response({"status": "error", "message": f"Ollama HTTP {response.status}: {err_txt}"}) else: - # OpenAI-compatible vision chat (LM Studio / Custom). - content = [{"type": "text", "text": _ANALYZE_PROMPT}] - for b64 in cleaned_b64_list: - content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}) + # OpenAI-compatible vision chat (LM Studio / Custom). + content = [{"type": "text", "text": _ANALYZE_PROMPT}] + for b64 in analysis_images: + content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}) payload = { "model": model_name, "messages": [{"role": "user", "content": content}],