diff --git a/ltx_director.py b/ltx_director.py index cf39beb..baf8c17 100644 --- a/ltx_director.py +++ b/ltx_director.py @@ -437,124 +437,14 @@ _PROVIDER_DEFAULTS = { "lmstudio": {"url": "http://127.0.0.1:1234", "model": ""}, "custom": {"url": "", "model": ""}, } -_OLLAMA_VISION_MODEL_HINTS = ( - "qwen2.5vl", - "qwen2.5-vl", - "qwen2vl", - "qwen2-vl", - "llava", - "bakllava", - "minicpm-v", - "minicpm", - "moondream", - "gemma3", - "internvl", -) - + _ANALYZE_PROMPT = ( - "Describe only the visible character in exactly two complete sentences. " - "Sentence 1 must cover hair, face, age impression, and any standout physical traits. " - "Sentence 2 must cover clothing, accessories, and overall silhouette. " - "Do not start with fragments like 'The'. Do not mention image quality, background, framing, or emotions." + "Describe the character's physical appearance in two concise sentences. " + "Specify their hair color/style, face details, and their clothing type/color. " + "Keep the entire response very brief." ) -def _extract_ollama_generated_text(resp_json: dict) -> str: - """Handle Ollama generate/chat variants and reasoning-capable models.""" - if not isinstance(resp_json, dict): - return "" - - candidates = [] - candidates.append(resp_json.get("response")) - candidates.append(resp_json.get("thinking")) - candidates.append(resp_json.get("content")) - - message = resp_json.get("message") - if isinstance(message, dict): - candidates.append(message.get("content")) - candidates.append(message.get("reasoning_content")) - candidates.append(message.get("thinking")) - - for candidate in candidates: - if isinstance(candidate, str) and candidate.strip(): - text = candidate.strip() - if "" in text: - text = text.split("")[-1].strip() - if text: - return text - return "" - - -def _analysis_text_is_usable(text: str) -> bool: - text = (text or "").strip() - if not text: - return False - if len(text) < 24: - return False - if len(text.split()) < 6: - return False - return True - - -def _sanitize_analysis_text(text: str) -> str: - """Strip prompt-echo / reasoning junk and keep the shortest usable visual description.""" - text = (text or "").strip() - if not text: - return "" - - if "" in text: - text = text.split("")[-1].strip() - - # Drop markdown emphasis and common prompt-echo scaffolding. - lines = [] - for raw_line in text.splitlines(): - line = raw_line.strip() - if not line: - continue - lower = line.lower() - if lower.startswith("the user wants"): - continue - if lower.startswith("sentence 1 requirements"): - continue - if lower.startswith("sentence 2 requirements"): - continue - if lower.startswith("requirements:"): - continue - if lower.startswith("let's interpret"): - continue - if lower.startswith("wait, the prompt says"): - continue - if lower.startswith("do not "): - continue - if line.startswith("- ") or line.startswith("* "): - continue - cleaned = line.replace("**", "").strip() - if cleaned: - lines.append(cleaned) - - text = " ".join(lines).strip() - text = re.sub(r"\s+", " ", text) - if not text: - return "" - - # If the model echoed instructions and then described the subject, keep the last descriptive sentences. - sentences = re.split(r"(?<=[.!?])\s+", text) - descriptive = [] - for sentence in sentences: - sentence = sentence.strip() - if not sentence: - continue - lower = sentence.lower() - if "prompt says" in lower or "requirements" in lower or "the user wants" in lower: - continue - descriptive.append(sentence) - - if descriptive: - text = " ".join(descriptive[-2:]).strip() - - return text - - 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: @@ -577,31 +467,6 @@ def _prepare_analysis_images(cleaned_b64_list: list[str], max_dim: int = 768, qu _compress_analysis_image_b64(b64, max_dim=max_dim, quality=quality) for b64 in cleaned_b64_list ] - - -def _extract_json_description(text: str) -> str: - text = (text or "").strip() - if not text: - return "" - try: - start = text.find("{") - end = text.rfind("}") - if start != -1 and end != -1 and end > start: - payload = json.loads(text[start:end + 1]) - if isinstance(payload, dict): - description = payload.get("description") - if isinstance(description, str): - return description.strip() - except Exception: - return "" - return "" - - -def _is_likely_vision_model_name(model_name: str) -> bool: - lower = (model_name or "").strip().lower() - if not lower: - return False - return any(hint in lower for hint in _OLLAMA_VISION_MODEL_HINTS) def _resolve_provider(data): @@ -635,13 +500,11 @@ async def analyze_character_endpoint(request): cleaned_b64_list.append(b64) 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')...", char_index + 1, provider, base_url, model_name) @@ -649,143 +512,40 @@ async def analyze_character_endpoint(request): try: async with aiohttp.ClientSession() as session: if provider == "ollama": - requested_model_name = model_name - if not _is_likely_vision_model_name(model_name): - try: - async with session.get(f"{base_url}/api/tags", timeout=20) as tags_response: - if tags_response.status == 200: - tags_json = await tags_response.json() - models = tags_json.get("models") or [] - candidate_names = [] - for item in models: - if isinstance(item, dict): - name = item.get("name") or item.get("model") - if isinstance(name, str): - candidate_names.append(name) - vision_candidates = [name for name in candidate_names if _is_likely_vision_model_name(name)] - if vision_candidates: - model_name = vision_candidates[0] - log.info( - "[LTXDirector] Auto-selected Ollama vision model '%s' instead of non-vision default '%s'.", - model_name, requested_model_name, - ) - except Exception: - pass - - if not _is_likely_vision_model_name(model_name): - return web.json_response({ - "status": "error", - "message": ( - "No Ollama vision model is configured. The current default " - f"'{requested_model_name}' is not a vision model. Install/select a vision model " - "such as qwen2.5vl, llava, moondream, minicpm-v, or gemma3." - ), - }) - - generated_text = "" + analysis_images = cleaned_b64_list payload = { "model": model_name, "prompt": _ANALYZE_PROMPT, "images": analysis_images, "stream": False, "keep_alive": 0, - "options": { - "temperature": 0.2, - "num_predict": 120, - }, } async with session.post(f"{base_url}/api/generate", json=payload, timeout=300) as response: if response.status != 200: err_txt = await response.text() 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) + payload["images"] = analysis_images + async with session.post(f"{base_url}/api/generate", json=payload, timeout=300) as retry_response: + if retry_response.status != 200: + retry_err = await retry_response.text() + return web.json_response({"status": "error", "message": f"Ollama HTTP {retry_response.status}: {retry_err}"}) + resp_json = await retry_response.json() + generated_text = (resp_json.get("response") or "").strip() 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. - if not _analysis_text_is_usable(generated_text): - chat_payload = { - "model": model_name, - "messages": [{ - "role": "user", - "content": _ANALYZE_PROMPT, - "images": analysis_images, - }], - "stream": False, - "keep_alive": 0, - "options": { - "temperature": 0.2, - "num_predict": 120, - }, - } - async with session.post(f"{base_url}/api/chat", json=chat_payload, timeout=300) as response: - 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}"}) - if not _analysis_text_is_usable(generated_text) and analysis_images: - json_prompt = ( - 'Return JSON only with this exact shape: ' - '{"description":"two concise sentences describing only the visible character\'s appearance"}' - ) - single_image = [analysis_images[0]] - json_payload = { - "model": model_name, - "prompt": json_prompt, - "images": single_image, - "stream": False, - "format": "json", - "keep_alive": 0, - "options": { - "temperature": 0.1, - "num_predict": 120, - }, - } - async with session.post(f"{base_url}/api/generate", json=json_payload, timeout=300) as response: - if response.status == 200: - resp_json = await response.json() - raw_text = _extract_ollama_generated_text(resp_json) - generated_text = _extract_json_description(raw_text) or raw_text + generated_text = (resp_json.get("response") or "").strip() else: # OpenAI-compatible vision chat (LM Studio / Custom). content = [{"type": "text", "text": _ANALYZE_PROMPT}] - for b64 in analysis_images: + for b64 in cleaned_b64_list: content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}) - payload = { - "model": model_name, - "messages": [{"role": "user", "content": content}], - "max_tokens": 2048, "stream": False, + payload = { + "model": model_name, + "messages": [{"role": "user", "content": content}], + "max_tokens": 2048, "stream": False, } async with session.post(f"{base_url}/v1/chat/completions", json=payload, timeout=120) as response: if response.status != 200: @@ -801,19 +561,15 @@ async def analyze_character_endpoint(request): generated_text = (msg.get("reasoning_content") or "").strip() except (KeyError, IndexError, TypeError): return web.json_response({"status": "error", "message": f"Unexpected response shape from {provider}."}) - except aiohttp.ClientConnectorError: - return web.json_response({ - "status": "error", - "message": f"Could not connect to {provider} at {base_url}. Make sure the server is running and reachable.", - }) - - generated_text = _sanitize_analysis_text(generated_text) - if not _analysis_text_is_usable(generated_text): + except aiohttp.ClientConnectorError: return web.json_response({ "status": "error", - "message": f"{provider} returned an empty or truncated analysis response.", + "message": f"Could not connect to {provider} at {base_url}. Make sure the server is running and reachable.", }) + if "" in generated_text: + generated_text = generated_text.split("")[-1].strip() + log.info("[LTXDirector] Analysis complete: %s", generated_text) return web.json_response({"status": "success", "description": generated_text})