@@ -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