Skip to content

Commit 284df51

Browse files
committed
feat(mtmd): broaden multimodal media extraction
- Broaden MTMD media extraction to support common multimodal content shapes used by model chat templates. - In addition to OpenAI-style image_url/audio_url/video_url chunks, accept image/audio/video typed chunks and direct media keys such as {"image": "..."}, {"audio": "..."}, or {"video": "..."}. This keeps the extracted media list aligned with templates that emit placeholders for image, audio, or video content without requiring URL-specific chunk names. - Add a shared helper for extracting URLs, local paths, existing data URIs, or inline base64 payloads from media content items. Preserve capability checks, strict input_audio format validation, and explicit errors for missing or ambiguous media payloads. Signed-off-by: JamePeng <jame_peng@sina.com>
1 parent d5a9f64 commit 284df51

1 file changed

Lines changed: 183 additions & 68 deletions

File tree

llama_cpp/llama_multimodal.py

Lines changed: 183 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -256,80 +256,195 @@ def close(self) -> None:
256256
def __del__(self) -> None:
257257
self.close()
258258

259-
def _get_media_items(self, messages: List[llama_types.ChatCompletionRequestMessage]) -> List[Dict[str, str]]:
259+
def _get_media_url(
260+
self,
261+
content: Dict[str, Any],
262+
keys: Tuple[str, ...],
263+
media_type: str,
264+
) -> str:
260265
"""
261-
Extracts all media payloads (images, audio) sequentially to maintain exact chronological order.
262-
Strictly enforces capability checks, raising exceptions if unsupported media is passed.
266+
Extract a media URL or data URI from a multimodal content item.
263267
264-
Returns:
265-
media_items: A list of dictionaries containing the media 'url' and its 'type' (image or audio).
268+
Different chat templates and client APIs may represent the same media
269+
payload with slightly different keys. For example, an image may appear as
270+
`image`, `image_url`, or a typed chunk with `{"type": "image", ...}`.
271+
This helper checks the provided keys in order and returns the first usable
272+
media payload.
273+
274+
Returns an empty string when none of the requested keys exist or when the
275+
payload shape is unsupported. The caller is responsible for raising a
276+
media-type-specific error when an empty value is not acceptable.
277+
"""
278+
# Try keys in priority order. This lets callers prefer canonical fields
279+
# such as "image" over compatibility aliases such as "image_url", while
280+
# still accepting either representation.
281+
value = None
282+
for key in keys:
283+
if key in content:
284+
value = content[key]
285+
break
286+
287+
# String payloads may already be URLs, local paths, or data URIs.
288+
if isinstance(value, str):
289+
return value
290+
291+
if isinstance(value, dict):
292+
# Common OpenAI-style shape:
293+
# {"image_url": {"url": "..."}}
294+
if "url" in value:
295+
return value["url"]
296+
297+
# Forward-compatible inline media shape:
298+
# {"audio": {"data": "...", "format": "wav"}}
299+
#
300+
# Convert it to a data URI so downstream media loading does not need
301+
# separate branches for raw base64 payloads.
302+
if "data" in value and "format" in value:
303+
media_format = value.get("format", "")
304+
media_data = value.get("data", "")
305+
if media_format and media_data:
306+
return f"data:{media_type}/{media_format};base64,{media_data}"
307+
308+
return ""
309+
310+
def _get_media_items(
311+
self,
312+
messages: List[llama_types.ChatCompletionRequestMessage],
313+
) -> List[Dict[str, str]]:
314+
"""
315+
Extract media payloads from chat messages in message/content order.
316+
317+
Supports OpenAI-style typed media chunks as well as template-friendly
318+
variants used by multimodal chat templates, such as:
319+
- {"type": "image_url", "image_url": {"url": "..."}}
320+
- {"type": "image", "image": "..."}
321+
- {"image": "..."}
322+
- {"type": "audio_url", "audio_url": {"url": "..."}}
323+
- {"type": "audio", "audio": "..."}
324+
- {"type": "input_audio", "input_audio": {"data": "...", "format": "wav"}}
325+
- {"type": "video_url", "video_url": {"url": "..."}}
326+
- {"type": "video", "video": "..."}
327+
- {"video": "..."}
328+
329+
The returned order must match the media placeholders emitted by the rendered
330+
chat template as closely as possible.
266331
"""
267332
media_items: List[Dict[str, str]] = []
333+
268334
for message in messages:
269-
if isinstance(message.get("content"), list):
270-
for content in message["content"]:
271-
content_type = content.get("type", "")
272-
273-
# 1. Vision Processing
274-
if content_type == "image_url":
275-
if not self.is_support_vision:
276-
raise ValueError(f"{self.log_prefix}: This mmproj model instance does not support image inputs.")
277-
278-
url = content["image_url"] if isinstance(content["image_url"], str) else content["image_url"]["url"]
279-
media_items.append({"url": url, "type": "image"})
280-
281-
# 2. Audio Processing
282-
elif content_type in ["audio", "audio_url", "input_audio"]:
283-
if not self.is_support_audio:
284-
raise ValueError(f"{self.log_prefix}: This mmproj model instance does not support audio inputs.")
285-
286-
# Case A: Handle custom/forward-compatible audio_url format
287-
if content_type == "audio_url" or content_type == "audio":
288-
audio_url = content[content_type]
289-
url = audio_url if isinstance(audio_url, str) else audio_url["url"]
290-
media_items.append({"url": url, "type": "audio"})
291-
# Case B: Handle OpenAI standard input_audio format
292-
elif content_type == "input_audio":
293-
input_audio = content.get("input_audio", {})
294-
if isinstance(input_audio, dict) and "data" in input_audio:
295-
# It might just be raw base64 data, we can format it as a data URI to reuse load_audio logic
296-
# input_audio: {
297-
# data: audio.base64Data,
298-
# format: audio.mimeType.includes('wav') ? 'wav' : 'mp3'
299-
# }
300-
audio_data = input_audio.get("data", "")
301-
audio_format = input_audio.get("format", "")
302-
303-
# Strictly align with llama.cpp (require wav/mp3)
304-
if audio_format not in ["wav", "mp3"]:
305-
raise ValueError(f"{self.log_prefix}: input_audio.format must be either 'wav' or 'mp3'")
306-
307-
# Format as a Data URI to reuse the unified load_media logic
308-
media_items.append({
309-
"url": f"data:audio/{audio_format};base64,{audio_data}",
310-
"type": "audio"
311-
})
312-
else:
313-
# Just a raw base64 data
314-
url = input_audio if isinstance(input_audio, str) else ""
315-
if url:
316-
media_items.append({"url": url, "type": "audio"})
317-
318-
# 3. Video Processing
319-
elif content_type == "video_url":
320-
if not self.is_support_video:
321-
raise ValueError(f"{self.log_prefix}: This libmtmd build does not support video inputs.")
322-
323-
video_url = content["video_url"]
324-
url = video_url if isinstance(video_url, str) else video_url["url"]
325-
media_items.append({"url": url, "type": "video"})
326-
327-
# 4. Text & Unknown Types
328-
elif content_type == "text":
329-
continue
335+
content_list = message.get("content")
336+
if not isinstance(content_list, list):
337+
continue
338+
339+
for content in content_list:
340+
if not isinstance(content, dict):
341+
continue
342+
343+
content_type = content.get("type", "")
344+
345+
has_image = (
346+
content_type in ("image", "image_url")
347+
or "image" in content
348+
or "image_url" in content
349+
)
350+
has_audio = (
351+
content_type in ("audio", "audio_url", "input_audio")
352+
or "audio" in content
353+
or "audio_url" in content
354+
or "input_audio" in content
355+
)
356+
has_video = (
357+
content_type in ("video", "video_url")
358+
or "video" in content
359+
or "video_url" in content
360+
)
361+
362+
media_kind_count = int(has_image) + int(has_audio) + int(has_video)
363+
if media_kind_count > 1:
364+
raise ValueError(
365+
f"{self.log_prefix}: content item contains multiple media types; "
366+
"each content item must contain only one of image, audio, or video."
367+
)
368+
369+
# 1. Vision Processing
370+
if has_image:
371+
if not self.is_support_vision:
372+
raise ValueError(
373+
f"{self.log_prefix}: This mmproj model instance does not support image inputs."
374+
)
375+
376+
url = self._get_media_url(
377+
content,
378+
keys=("image", "image_url"),
379+
media_type="image",
380+
)
381+
if not url:
382+
raise ValueError(f"{self.log_prefix}: missing image url/data.")
383+
384+
media_items.append({"url": url, "type": "image"})
385+
386+
# 2. Audio Processing
387+
elif has_audio:
388+
if not self.is_support_audio:
389+
raise ValueError(
390+
f"{self.log_prefix}: This mmproj model instance does not support audio inputs."
391+
)
392+
393+
if content_type == "input_audio" or "input_audio" in content:
394+
input_audio = content.get("input_audio", {})
395+
396+
if isinstance(input_audio, dict) and "data" in input_audio:
397+
audio_data = input_audio.get("data", "")
398+
audio_format = input_audio.get("format", "")
399+
400+
# Strictly align with llama.cpp.
401+
if audio_format not in ["wav", "mp3"]:
402+
raise ValueError(
403+
f"{self.log_prefix}: input_audio.format must be either 'wav' or 'mp3'"
404+
)
405+
406+
url = f"data:audio/{audio_format};base64,{audio_data}"
407+
else:
408+
url = input_audio if isinstance(input_audio, str) else ""
330409
else:
331-
if self.verbose:
332-
print(f"{self.log_prefix}: Ignored unknown content type '{content_type}'.", file=sys.stderr)
410+
url = self._get_media_url(
411+
content,
412+
keys=("audio", "audio_url"),
413+
media_type="audio",
414+
)
415+
416+
if not url:
417+
raise ValueError(f"{self.log_prefix}: missing audio url/data.")
418+
419+
media_items.append({"url": url, "type": "audio"})
420+
421+
# 3. Video Processing
422+
elif has_video:
423+
if not self.is_support_video:
424+
raise ValueError(
425+
f"{self.log_prefix}: This libmtmd build does not support video inputs."
426+
)
427+
428+
url = self._get_media_url(
429+
content,
430+
keys=("video", "video_url"),
431+
media_type="video",
432+
)
433+
if not url:
434+
raise ValueError(f"{self.log_prefix}: missing video url/data.")
435+
436+
media_items.append({"url": url, "type": "video"})
437+
438+
# 4. Text & Unknown Types
439+
elif content_type == "text" or "text" in content:
440+
continue
441+
else:
442+
if self.verbose:
443+
print(
444+
f"{self.log_prefix}: ignored unknown content type '{content_type}'.",
445+
file=sys.stderr,
446+
)
447+
333448
return media_items
334449

335450
def _create_bitmap_from_bytes(self, media_bytes: bytes):

0 commit comments

Comments
 (0)