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})