huggingface/transformers · #49382

Audio from video in jinja

zucchini-nlp · merged Nov 2, 202617 files · 1288 + / 336 −
src/transformers/audio_utils.py1 + / 1 −
@@ -223,7 +223,7 @@ def load_audio(audio: str | np.ndarray, sampling_rate=16000, timeout=None, backe     if not isinstance(audio, str):         raise TypeError(             "Incorrect format used for `audio`. Should be a numpy array or a `str`: an `http(s)://` URL, "-            "a local file path, or a base64-encoded string (optionally wrapped as a `data:...` URI)."+            f"a local file path, or a base64-encoded string (optionally wrapped as a `data:...` URI) but got {type(audio)}."         )      # torchcodec handles audio/video; librosa only plain audio. `backend` lets callers pin one.
src/transformers/models/embedding_gemma2/modular_embedding_gemma2.py159 + / 17 −
@@ -30,7 +30,7 @@ from ...modeling_flash_attention_utils import FlashAttentionKwargs from ...modeling_outputs import BaseModelOutput, BaseModelOutputWithPooling from ...modeling_utils import ALL_ATTENTION_FUNCTIONS, PreTrainedModel-from ...processing_utils import ProcessingKwargs, ProcessorMixin, Unpack+from ...processing_utils import AudioKwargs, OmniModalProcessorMixin, Unpack, VideosKwargs from ...tokenization_utils_base import PreTokenizedInput, TextInput from ...utils import (     TransformersKwargs,@@ -765,11 +765,14 @@ class EmbeddingGemma2VideoProcessorKwargs(Gemma4VideoProcessorKwargs):         The strategy used to cut the total number of sampled frames down to fit into the budget.         Can be set only to "uniform" or "truncate". Applied after FPS-based sampling, and on its         own when FPS-based sampling is off or not applicable.+    load_audio_from_video (`bool`, *optional*):+        Whether to load the audio track of an input video or not.     """      add_timestamps: bool     max_frames: int | None     overflow_strategy: str | None+    load_audio_from_video: bool   class EmbeddingGemma2VideoProcessor(Gemma4VideoProcessor):@@ -842,18 +845,14 @@ def sample_frames(  class EmbeddingGemma2ProcessorKwargs(Gemma4ProcessorKwargs):     _defaults = {-        "text_kwargs": {-            "padding": True,-        },-        "images_kwargs": {-            "do_convert_rgb": True,-        },+        "text_kwargs": {"padding": True},+        "images_kwargs": {"do_convert_rgb": True},         "audio_kwargs": {},         "videos_kwargs": {"return_metadata": True},     }  -class EmbeddingGemma2Processor(Gemma4Processor):+class EmbeddingGemma2Processor(OmniModalProcessorMixin, Gemma4Processor):     valid_processor_kwargs = EmbeddingGemma2ProcessorKwargs      def model_input_names(self):@@ -868,7 +867,7 @@ def prepare_inputs_layout(         **kwargs,     ):         # When `text` is None, record per-sample counts for `audio` and `videos` before-        # `ProcessorMixin.prepare_inputs_layout` runs `make_list_of_audio` and `make_batched_videos`,+        # `OmniModalProcessorMixin.prepare_inputs_layout` runs `make_list_of_audio` and `make_batched_videos`,         # which flatten 2D nested per-sample lists into a 1D list of total items and discard sample         # boundaries. `images` does not need this because `make_nested_list_of_images` preserves the         # outer per-sample list structure.@@ -887,7 +886,7 @@ def prepare_inputs_layout(                 else None             ) -        images, text, videos, audio = ProcessorMixin.prepare_inputs_layout(+        images, text, videos, audio = OmniModalProcessorMixin.prepare_inputs_layout(             self, images=images, text=text, videos=videos, audio=audio, **kwargs         ) @@ -929,9 +928,9 @@ def validate_inputs(         text: TextInput | PreTokenizedInput | list[TextInput] | list[PreTokenizedInput] = None,         videos: VideoInput = None,         audio: AudioInput = None,-        **kwargs: Unpack[ProcessingKwargs],+        **kwargs: Unpack[EmbeddingGemma2ProcessorKwargs],     ):-        ProcessorMixin.validate_inputs(self, images=images, text=text, **kwargs)+        OmniModalProcessorMixin.validate_inputs(self, images=images, text=text, **kwargs)          # Unlike Gemma 4, any single modality on its own is a valid embedding input.         if text is None and images is None and videos is None and audio is None:@@ -985,32 +984,175 @@ def validate_inputs(                         f"Found {sum(n_audio_in_text)} {self.audio_token} tokens in the text but no audio inputs were passed."                     ) -    def replace_video_token(self, video_inputs: dict, video_idx: int, **kwargs) -> str:+    @staticmethod+    def timestamp_fmt(sec: int | float) -> str:+        # Round (not floor): at fractional fps (e.g. 29.97) sampled timestamps land just below the nominal second+        sec = round(sec)+        return f"{sec // 60:02d}:{sec % 60:02d}"++    def replace_video_with_audio_token(+        self,+        video_inputs: dict,+        video_idx: int,+        processed_audio: dict | None = None,+        video_to_audio_indices: list | None = None,+        **kwargs: VideosKwargs,+    ) -> str:+        # When `load_audio_from_video` -> format is `mm:ss, vision_tokens, audio_tokens, mm:ss, vision_tokens, audio_tokens [...]`+        metadata = video_inputs["video_metadata"][video_idx]+        num_soft_tokens = video_inputs["num_soft_tokens_per_video"][video_idx]++        # Round (not floor) so 29.97fps timestamps like 0.9676 / 1.9686 land on 1s / 2s+        frame_seconds = [round(s) for s in metadata.timestamps]+        num_frames = len(frame_seconds)++        # Mirrors `max(1, int(metadata.duration * fps))` in `sample_frames` for fps=1+        total_seconds = max(1, int(metadata.duration))++        num_audio_tokens_per_sec = self.audio_frame_boundary(seconds=1)++        mapped_audio_idx = video_to_audio_indices[video_idx]+        audio_mask = processed_audio["input_features_mask"][mapped_audio_idx]+        num_audio_tokens_total = self.compute_sscp(audio_mask)++        # Each 1s chunk of video/audio is wrapped in special tokens:+        # "00:00 <BOI> [frame image tokens] <EOI> 00:00 <BOA> [audio tokens] <EOA> ..."+        segments = []+        audio_secs = range(frame_seconds[0], total_seconds)+        counts = [+            min(num_audio_tokens_per_sec, max(0, num_audio_tokens_total - k * num_audio_tokens_per_sec))+            for k in range(len(audio_secs))+        ]+        # Token count per audio second; the last chunk absorbs rounding/sscp leftovers+        if counts:+            counts[-1] += num_audio_tokens_total - sum(counts)+        audio_tokens = dict(zip(audio_secs, counts))+        for i, frame_sec in enumerate(frame_seconds):+            segments.append(+                f"{self.timestamp_fmt(frame_sec)} {self.boi_token}{self.video_token * num_soft_tokens}{self.eoi_token}"+            )++            # 1s audio chunks that follow this frame before the next one+            end_sec = frame_seconds[i + 1] if i < num_frames - 1 else total_seconds+            for sec in range(frame_sec, end_sec):+                if audio_tokens.get(sec, 0) > 0:+                    segments.append(+                        f"{self.timestamp_fmt(sec)} {self.boa_token}{self.audio_token * audio_tokens[sec]}{self.eoa_token}"+                    )++        return " ".join(segments)++    def replace_video_token(+        self,+        video_inputs: dict,+        video_idx: int,+        processed_audio: dict | None = None,+        video_to_audio_indices: list | None = None,+        **kwargs: VideosKwargs,+    ) -> str:         num_soft_tokens = video_inputs["num_soft_tokens_per_video"][video_idx]         add_timestamps = kwargs.get("add_timestamps", self.video_processor.add_timestamps)          # Visual-only mode: one block per frame, no timestamps         if not add_timestamps:+            if kwargs.get("load_audio_from_video"):+                raise ValueError("You must allow adding timestamps when `load_audio_from_video=True`!")+             # `pixel_values_videos` is a flat frame sequence, so its leading axis indexes frames, not videos             num_frames = int(video_inputs["num_frames_per_video"][video_idx])             frame_str = f"{self.boi_token}{self.video_token * num_soft_tokens}{self.eoi_token}"             return "".join([frame_str] * num_frames)          metadata = video_inputs["video_metadata"][video_idx]-         if metadata.fps is None:             raise ValueError(                 "Asked to build a prompt with frame timestamps, but no `fps` was provided in video metadata. "                 "The capture rate of already-decoded frames cannot be inferred. Please pass a `VideoMetadata` "                 "object with a valid `fps`, or set `add_timestamps=False`."             ) -        # mm:ss format for timestamps-        timestamp_str = [f"{int(seconds // 60):02d}:{int(seconds % 60):02d}" for seconds in metadata.timestamps]+        # When loading audio from video and if current video has an associated audio, i.e. no silent+        if kwargs.get("load_audio_from_video") and video_to_audio_indices[video_idx] is not None:+            return self.replace_video_with_audio_token(+                video_inputs, video_idx, processed_audio, video_to_audio_indices, **kwargs+            )++        # General timestamp format is `mm:ss` per each video frame/tokens         return " ".join(-            [f"{t} {self.boi_token}{self.video_token * num_soft_tokens}{self.eoi_token}" for t in timestamp_str]+            [+                f"{self.timestamp_fmt(s)} {self.boi_token}{self.video_token * num_soft_tokens}{self.eoi_token}"+                for s in metadata.timestamps+            ]         ) +    def replace_audio_token(self, audio_inputs: dict, audio_idx: int, **kwargs: AudioKwargs) -> str:+        mask = audio_inputs["input_features_mask"][audio_idx]+        num_audio_tokens_total = self.compute_sscp(mask)+        return f"{self.boa_token}{self.audio_token * num_audio_tokens_total}{self.eoa_token}"++    def _compute_audio_num_tokens(self, audio_waveform, sampling_rate: int) -> int:+        """Number of audio soft tokens, replicating the encoder's seq-length arithmetic.++        Mirrors EmbeddingGemma2AudioFeatureExtractor mel framing + the two stride-2 Conv2d+        subsampling layers in EmbeddingGemma2AudioSubSampleConvProjection, capped at+        ``audio_seq_length``. Must match ``audio_mask.sum()`` from the audio tower or+        vLLM's ``_merge_multimodal_embeddings`` will raise on a length mismatch.++        Args:+            audio_waveform: A 1-D array or list containing the raw audio samples.+            sampling_rate: The sampling rate of the audio waveform in Hz.++        Returns:+            The number of audio soft tokens to insert as placeholders.+        """+        num_samples = len(audio_waveform)++        # Step 1: mel frames (matches feature_extraction_embedding_gemma2.py)+        frame_size_for_unfold = self.feature_extractor.frame_length + 1+        pad_left = self.feature_extractor.frame_length // 2  # semicausal time padding+        num_mel_frames = (num_samples + pad_left - frame_size_for_unfold) // self.feature_extractor.hop_length + 1+        if num_mel_frames <= 0:+            return 0++        # Step 2: SSCP subsampling. Two stride-2 Conv2d layers (kernel=3, pad=1)+        num_audio_tokens = self.compute_sscp(num_mel_frames)++        # Cap at the configured maximum+        return min(num_audio_tokens, self.audio_seq_length)++    def audio_frame_boundary(self, seconds: float) -> int:+        """Number of `audio_tokens` whose analysis window falls within [0, seconds)."""+        # early exit if audio truncated and current video frame has no corresponding track+        if seconds == 0:+            return 0++        input_raw_length = round(seconds * self.feature_extractor.sampling_rate)+        boundary = (+            input_raw_length - self.feature_extractor.frame_length // 2 - 1+        ) // self.feature_extractor.hop_length + 1+        mels_per_chunk = max(boundary, 0)+        return self.compute_sscp(mels_per_chunk)++    @staticmethod+    def compute_sscp(mask_or_len: int | np.ndarray):+        """+        SSCP subsampling. Two stride-2 Conv2d layers (kernel=3, pad=1)+        values same as in EmbeddingGemma2AudioSubSampleConvProjection(Layer)+        """+        sscp_num_layers = 2+        sscp_kernel, sscp_stride, sscp_padding = 3, 2, 1+        if isinstance(mask_or_len, (int, np.integer)):+            for _ in range(sscp_num_layers):+                # Conv1d/2d output-length along the time axis (dilation=1).+                mask_or_len = (mask_or_len + 2 * sscp_padding - sscp_kernel) // sscp_stride + 1+            return mask_or_len++        for _ in range(2):+            mask_or_len = mask_or_len[::sscp_stride][+                : (len(mask_or_len) + 2 * sscp_padding - sscp_kernel) // sscp_stride + 1+            ]+        return int(mask_or_len.sum())+  __all__ = [     "EmbeddingGemma2Config",
src/transformers/models/embedding_gemma2/processing_embedding_gemma2.py131 + / 38 −
@@ -24,7 +24,14 @@  from ...audio_utils import AudioInput, is_valid_audio from ...image_utils import ImageInput, make_nested_list_of_images-from ...processing_utils import MultiModalData, ProcessingKwargs, ProcessorMixin, Unpack+from ...processing_utils import (+    AudioKwargs,+    MultiModalData,+    OmniModalProcessorMixin,+    ProcessingKwargs,+    Unpack,+    VideosKwargs,+) from ...tokenization_utils_base import PreTokenizedInput, TextInput from ...utils import (     auto_docstring,@@ -34,12 +41,8 @@  class EmbeddingGemma2ProcessorKwargs(ProcessingKwargs, total=False):     _defaults = {-        "text_kwargs": {-            "padding": True,-        },-        "images_kwargs": {-            "do_convert_rgb": True,-        },+        "text_kwargs": {"padding": True},+        "images_kwargs": {"do_convert_rgb": True},         "audio_kwargs": {},         "videos_kwargs": {"return_metadata": True},     }@@ -100,7 +103,7 @@ def get_aspect_ratio_preserving_size(   @auto_docstring-class EmbeddingGemma2Processor(ProcessorMixin):+class EmbeddingGemma2Processor(OmniModalProcessorMixin):     valid_processor_kwargs = EmbeddingGemma2ProcessorKwargs      def __init__(@@ -168,7 +171,7 @@ def prepare_inputs_layout(         **kwargs,     ):         # When `text` is None, record per-sample counts for `audio` and `videos` before-        # `ProcessorMixin.prepare_inputs_layout` runs `make_list_of_audio` and `make_batched_videos`,+        # `OmniModalProcessorMixin.prepare_inputs_layout` runs `make_list_of_audio` and `make_batched_videos`,         # which flatten 2D nested per-sample lists into a 1D list of total items and discard sample         # boundaries. `images` does not need this because `make_nested_list_of_images` preserves the         # outer per-sample list structure.@@ -229,7 +232,7 @@ def validate_inputs(         text: TextInput | PreTokenizedInput | list[TextInput] | list[PreTokenizedInput] = None,         videos: VideoInput = None,         audio: AudioInput = None,-        **kwargs: Unpack[ProcessingKwargs],+        **kwargs: Unpack[EmbeddingGemma2ProcessorKwargs],     ):         super().validate_inputs(images=images, text=text, **kwargs) @@ -289,44 +292,53 @@ def replace_image_token(self, image_inputs: dict, image_idx: int, **kwargs) -> s         num_soft_tokens = image_inputs["num_soft_tokens_per_image"][image_idx]         return f"{self.boi_token}{self.image_token * num_soft_tokens}{self.eoi_token}" -    def replace_video_token(self, video_inputs: dict, video_idx: int, **kwargs) -> str:+    def replace_video_token(+        self,+        video_inputs: dict,+        video_idx: int,+        processed_audio: dict | None = None,+        video_to_audio_indices: list | None = None,+        **kwargs: VideosKwargs,+    ) -> str:         num_soft_tokens = video_inputs["num_soft_tokens_per_video"][video_idx]         add_timestamps = kwargs.get("add_timestamps", self.video_processor.add_timestamps)          # Visual-only mode: one block per frame, no timestamps         if not add_timestamps:+            if kwargs.get("load_audio_from_video"):+                raise ValueError("You must allow adding timestamps when `load_audio_from_video=True`!")+             # `pixel_values_videos` is a flat frame sequence, so its leading axis indexes frames, not videos             num_frames = int(video_inputs["num_frames_per_video"][video_idx])             frame_str = f"{self.boi_token}{self.video_token * num_soft_tokens}{self.eoi_token}"             return "".join([frame_str] * num_frames)          metadata = video_inputs["video_metadata"][video_idx]-         if metadata.fps is None:             raise ValueError(                 "Asked to build a prompt with frame timestamps, but no `fps` was provided in video metadata. "                 "The capture rate of already-decoded frames cannot be inferred. Please pass a `VideoMetadata` "                 "object with a valid `fps`, or set `add_timestamps=False`."             ) -        # mm:ss format for timestamps-        timestamp_str = [f"{int(seconds // 60):02d}:{int(seconds % 60):02d}" for seconds in metadata.timestamps]+        # When loading audio from video and if current video has an associated audio, i.e. no silent+        if kwargs.get("load_audio_from_video") and video_to_audio_indices[video_idx] is not None:+            return self.replace_video_with_audio_token(+                video_inputs, video_idx, processed_audio, video_to_audio_indices, **kwargs+            )++        # General timestamp format is `mm:ss` per each video frame/tokens         return " ".join(-            [f"{t} {self.boi_token}{self.video_token * num_soft_tokens}{self.eoi_token}" for t in timestamp_str]+            [+                f"{self.timestamp_fmt(s)} {self.boi_token}{self.video_token * num_soft_tokens}{self.eoi_token}"+                for s in metadata.timestamps+            ]         ) -    def replace_audio_token(self, audio_inputs: dict, audio_idx: int, **kwargs) -> str:-        # TODO: Add tests for audio-only processor inputs.+    def replace_audio_token(self, audio_inputs: dict, audio_idx: int, **kwargs: AudioKwargs) -> str:         mask = audio_inputs["input_features_mask"][audio_idx]--        # Simulate two stride-2 conv blocks on the mask-        t = len(mask)-        for _ in range(2):-            t_out = (t + 2 - 3) // 2 + 1-            mask = mask[::2][:t_out]-            t = len(mask)--        return f"{self.boa_token}{self.audio_token * int(mask.sum())}{self.eoa_token}"+        num_audio_tokens_total = self.compute_sscp(mask)+        return f"{self.boa_token}{self.audio_token * num_audio_tokens_total}{self.eoa_token}"      def _get_num_multimodal_tokens(self, image_sizes=None, video_sizes=None, audio_lengths=None, **kwargs):         """@@ -435,24 +447,105 @@ def _compute_audio_num_tokens(self, audio_waveform, sampling_rate: int) -> int:             return 0          # Step 2: SSCP subsampling. Two stride-2 Conv2d layers (kernel=3, pad=1)-        # from EmbeddingGemma2AudioSubSampleConvProjection(Layer) in modeling_embedding_gemma2.py.-        # These are architectural constants, not exposed via EmbeddingGemma2AudioConfig-        # (note: config.conv_kernel_size=5 is the *conformer* depthwise conv, NOT-        # this one). The layer count tracks len(subsampling_conv_channels)==2.-        # Keep in sync if the audio encoder's subsampling stack changes.-        sscp_num_layers = 2-        sscp_kernel, sscp_stride, sscp_padding = 3, 2, 1-        t = num_mel_frames-        for _ in range(sscp_num_layers):-            # Conv1d/2d output-length along the time axis (dilation=1).-            t = (t + 2 * sscp_padding - sscp_kernel) // sscp_stride + 1+        num_audio_tokens = self.compute_sscp(num_mel_frames)          # Cap at the configured maximum-        return min(t, self.audio_seq_length)+        return min(num_audio_tokens, self.audio_seq_length)      @property     def unused_input_names(self) -> list[str]:         return ["num_soft_tokens_per_image", "num_soft_tokens_per_video"] +    @staticmethod+    def timestamp_fmt(sec: int | float) -> str:+        # Round (not floor): at fractional fps (e.g. 29.97) sampled timestamps land just below the nominal second+        sec = round(sec)+        return f"{sec // 60:02d}:{sec % 60:02d}"++    def replace_video_with_audio_token(+        self,+        video_inputs: dict,+        video_idx: int,+        processed_audio: dict | None = None,+        video_to_audio_indices: list | None = None,+        **kwargs: VideosKwargs,+    ) -> str:+        # When `load_audio_from_video` -> format is `mm:ss, vision_tokens, audio_tokens, mm:ss, vision_tokens, audio_tokens [...]`+        metadata = video_inputs["video_metadata"][video_idx]+        num_soft_tokens = video_inputs["num_soft_tokens_per_video"][video_idx]++        # Round (not floor) so 29.97fps timestamps like 0.9676 / 1.9686 land on 1s / 2s+        frame_seconds = [round(s) for s in metadata.timestamps]+        num_frames = len(frame_seconds)++        # Mirrors `max(1, int(metadata.duration * fps))` in `sample_frames` for fps=1+        total_seconds = max(1, int(metadata.duration))++        num_audio_tokens_per_sec = self.audio_frame_boundary(seconds=1)++        mapped_audio_idx = video_to_audio_indices[video_idx]+        audio_mask = processed_audio["input_features_mask"][mapped_audio_idx]+        num_audio_tokens_total = self.compute_sscp(audio_mask)++        # Each 1s chunk of video/audio is wrapped in special tokens:+        # "00:00 <BOI> [frame image tokens] <EOI> 00:00 <BOA> [audio tokens] <EOA> ..."+        segments = []+        audio_secs = range(frame_seconds[0], total_seconds)+        counts = [+            min(num_audio_tokens_per_sec, max(0, num_audio_tokens_total - k * num_audio_tokens_per_sec))+            for k in range(len(audio_secs))+        ]+        # Token count per audio second; the last chunk absorbs rounding/sscp leftovers+        if counts:+            counts[-1] += num_audio_tokens_total - sum(counts)+        audio_tokens = dict(zip(audio_secs, counts))+        for i, frame_sec in enumerate(frame_seconds):+            segments.append(+                f"{self.timestamp_fmt(frame_sec)} {self.boi_token}{self.video_token * num_soft_tokens}{self.eoi_token}"+            )++            # 1s audio chunks that follow this frame before the next one+            end_sec = frame_seconds[i + 1] if i < num_frames - 1 else total_seconds+            for sec in range(frame_sec, end_sec):+                if audio_tokens.get(sec, 0) > 0:+                    segments.append(+                        f"{self.timestamp_fmt(sec)} {self.boa_token}{self.audio_token * audio_tokens[sec]}{self.eoa_token}"+                    )++        return " ".join(segments)++    def audio_frame_boundary(self, seconds: float) -> int:+        """Number of `audio_tokens` whose analysis window falls within [0, seconds)."""+        # early exit if audio truncated and current video frame has no corresponding track+        if seconds == 0:+            return 0++        input_raw_length = round(seconds * self.feature_extractor.sampling_rate)+        boundary = (+            input_raw_length - self.feature_extractor.frame_length // 2 - 1+        ) // self.feature_extractor.hop_length + 1+        mels_per_chunk = max(boundary, 0)+        return self.compute_sscp(mels_per_chunk)++    @staticmethod+    def compute_sscp(mask_or_len: int | np.ndarray):+        """+        SSCP subsampling. Two stride-2 Conv2d layers (kernel=3, pad=1)+        values same as in EmbeddingGemma2AudioSubSampleConvProjection(Layer)+        """+        sscp_num_layers = 2+        sscp_kernel, sscp_stride, sscp_padding = 3, 2, 1+        if isinstance(mask_or_len, (int, np.integer)):+            for _ in range(sscp_num_layers):+                # Conv1d/2d output-length along the time axis (dilation=1).+                mask_or_len = (mask_or_len + 2 * sscp_padding - sscp_kernel) // sscp_stride + 1+            return mask_or_len++        for _ in range(2):+            mask_or_len = mask_or_len[::sscp_stride][+                : (len(mask_or_len) + 2 * sscp_padding - sscp_kernel) // sscp_stride + 1+            ]+        return int(mask_or_len.sum())+  __all__ = ["EmbeddingGemma2Processor"]
src/transformers/models/embedding_gemma2/video_processing_embedding_gemma2.py3 + / 0 −
@@ -69,6 +69,8 @@ class EmbeddingGemma2VideoProcessorKwargs(VideosKwargs, total=False):         The strategy used to cut the total number of sampled frames down to fit into the budget.         Can be set only to "uniform" or "truncate". Applied after FPS-based sampling, and on its         own when FPS-based sampling is off or not applicable.+    load_audio_from_video (`bool`, *optional*):+        Whether to load the audio track of an input video or not.     """      patch_size: int@@ -78,6 +80,7 @@ class EmbeddingGemma2VideoProcessorKwargs(VideosKwargs, total=False):     add_timestamps: bool     max_frames: int | None     overflow_strategy: str | None+    load_audio_from_video: bool   _SUPPORTED_SOFT_TOKENS = (70, 140, 280, 560, 1120)
src/transformers/models/qwen2_5_omni/processing_qwen2_5_omni.py24 + / 5 −
@@ -22,12 +22,15 @@  from ...feature_extraction_utils import BatchFeature from ...image_utils import ImageInput-from ...processing_utils import ProcessingKwargs, ProcessorMixin, Unpack, VideosKwargs+from ...processing_utils import OmniModalProcessorMixin, ProcessingKwargs, Unpack, VideosKwargs from ...tokenization_utils_base import AudioInput, PreTokenizedInput, TextInput-from ...utils import auto_docstring+from ...utils import auto_docstring, logging from ...video_utils import VideoInput  +logger = logging.get_logger(__name__)++ # Redefine kwargs for videos because Qwen-Omni uses some kwargs for processing omni # and does not use them in video processor class class Qwen2_5_OmniVideosKwargs(VideosKwargs, total=False):@@ -53,9 +56,11 @@ class Qwen2_5_OmniVideosKwargs(VideosKwargs, total=False):     max_frames (`int`, *optional*):         Maximum number of frames to extract from the video. Longer videos will be truncated or sampled to fit         within this limit.-    use_audio_in_video (`bool`, *optional*, defaults to `False`):+    load_audio_from_video (`bool`, *optional*, defaults to `False`):         Whether to incorporate audio information when processing videos. When enabled, audio tokens are         interleaved with video tokens based on temporal alignment, creating a unified multimodal representation.+    use_audio_in_video (`bool`, *optional*, defaults to `False`):+        Deprecated arg - use `load_audio_from_video` instead.     seconds_per_chunk (`float`, *optional*, defaults to `2.0`):         The duration (in seconds) of each video chunk when splitting long videos. This parameter controls how         videos are divided into temporal segments for processing.@@ -72,6 +77,7 @@ class Qwen2_5_OmniVideosKwargs(VideosKwargs, total=False):     min_frames: int     max_frames: int     use_audio_in_video: bool+    load_audio_from_video: bool     seconds_per_chunk: float     position_id_per_seconds: int | float @@ -102,7 +108,7 @@ class Qwen2_5OmniProcessorKwargs(ProcessingKwargs, total=False):   @auto_docstring-class Qwen2_5OmniProcessor(ProcessorMixin):+class Qwen2_5OmniProcessor(OmniModalProcessorMixin):     valid_processor_kwargs = Qwen2_5OmniProcessorKwargs      def __init__(@@ -138,6 +144,19 @@ def __call__(         seconds_per_chunk = output_kwargs["videos_kwargs"].pop("seconds_per_chunk")         position_id_per_seconds = output_kwargs["videos_kwargs"].pop("position_id_per_seconds")         use_audio_in_video = output_kwargs["videos_kwargs"].pop("use_audio_in_video")+        load_audio_from_video = output_kwargs["videos_kwargs"].pop("load_audio_from_video", False)+        if use_audio_in_video:+            logger.warning(+                "`use_audio_in_video` is depreacted and will be removed in v5.25, use `load_audio_from_video` instead"+            )+            load_audio_from_video = True++        if load_audio_from_video:+            audio, video_to_audio_indices = self._resolve_audio_from_video(+                videos,+                audio=audio,+                audio_from_video_indices=None,+            )          if audio is not None:             output_kwargs["audio_kwargs"]["padding"] = "max_length"  # Support "max_length" padding only here@@ -187,7 +206,7 @@ def __call__(                 image_grid_thw,                 video_grid_thw,                 video_second_per_grid=video_second_per_grid,-                use_audio_in_video=use_audio_in_video,+                use_audio_in_video=load_audio_from_video,                 position_id_per_seconds=position_id_per_seconds,                 seconds_per_chunk=seconds_per_chunk,             )
src/transformers/models/qwen3_omni_moe/modular_qwen3_omni_moe.py17 + / 4 −
@@ -43,7 +43,7 @@ ) from ...modeling_rope_utils import RopeParameters from ...modeling_utils import PreTrainedModel-from ...processing_utils import ProcessorMixin, Unpack+from ...processing_utils import OmniModalProcessorMixin, Unpack from ...tokenization_utils_base import TextInput from ...utils import auto_docstring, can_return_tuple, logging from ...utils.generic import (@@ -2772,7 +2772,7 @@ class Qwen3OmniMoeProcessorKwargs(Qwen2_5OmniProcessorKwargs):     }  -class Qwen3OmniMoeProcessor(Qwen2_5OmniProcessor, ProcessorMixin):+class Qwen3OmniMoeProcessor(Qwen2_5OmniProcessor):     def replace_multimodal_special_tokens(         self,         text,@@ -2874,6 +2874,19 @@ def __call__(         fps = output_kwargs["videos_kwargs"].get("fps", 1.0)         fps = fps if fps is not None else 1.0         n_window = output_kwargs["audio_kwargs"].pop("n_window", 50)+        load_audio_from_video = output_kwargs["videos_kwargs"].pop("load_audio_from_video", False)+        if use_audio_in_video:+            logger.warning(+                "`use_audio_in_video` is depreacted and will be removed in v5.25, use `load_audio_from_video` instead"+            )+            load_audio_from_video = True++        if load_audio_from_video:+            audio, video_to_audio_indices = self._resolve_audio_from_video(+                videos,+                audio=audio,+                audio_from_video_indices=None,+            )          if audio is not None:             audio_inputs = self.feature_extractor(audio, **output_kwargs["audio_kwargs"])@@ -2919,7 +2932,7 @@ def __call__(             image_grid_thw,             video_grid_thw,             video_second_per_grid=video_second_per_grid,-            use_audio_in_video=use_audio_in_video,+            use_audio_in_video=load_audio_from_video,             position_id_per_seconds=position_id_per_seconds,             seconds_per_chunk=seconds_per_chunk,         )@@ -2932,7 +2945,7 @@ def __call__(         )      def apply_chat_template(self, conversations, chat_template=None, **kwargs):-        return ProcessorMixin.apply_chat_template(self, conversations, chat_template, **kwargs)+        return OmniModalProcessorMixin.apply_chat_template(self, conversations, chat_template, **kwargs)   __all__ = [
src/transformers/models/qwen3_omni_moe/processing_qwen3_omni_moe.py24 + / 5 −
@@ -25,12 +25,15 @@ from ...audio_utils import AudioInput from ...feature_extraction_utils import BatchFeature from ...image_utils import ImageInput-from ...processing_utils import ProcessingKwargs, ProcessorMixin, Unpack, VideosKwargs+from ...processing_utils import OmniModalProcessorMixin, ProcessingKwargs, Unpack, VideosKwargs from ...tokenization_utils_base import TextInput-from ...utils import auto_docstring+from ...utils import auto_docstring, logging from ...video_utils import VideoInput  +logger = logging.get_logger(__name__)++ # Redefine kwargs for videos because Qwen-Omni uses some kwargs for processing omni # and does not use them in video processor class class Qwen3OmniMoeVideosKwargs(VideosKwargs, total=False):@@ -56,9 +59,11 @@ class Qwen3OmniMoeVideosKwargs(VideosKwargs, total=False):     max_frames (`int`, *optional*):         Maximum number of frames to extract from the video. Longer videos will be truncated or sampled to fit         within this limit.-    use_audio_in_video (`bool`, *optional*, defaults to `False`):+    load_audio_from_video (`bool`, *optional*, defaults to `False`):         Whether to incorporate audio information when processing videos. When enabled, audio tokens are         interleaved with video tokens based on temporal alignment, creating a unified multimodal representation.+    use_audio_in_video (`bool`, *optional*, defaults to `False`):+        Deprecated arg - use `load_audio_from_video` instead.     seconds_per_chunk (`float`, *optional*, defaults to `2.0`):         The duration (in seconds) of each video chunk when splitting long videos. This parameter controls how         videos are divided into temporal segments for processing.@@ -75,6 +80,7 @@ class Qwen3OmniMoeVideosKwargs(VideosKwargs, total=False):     min_frames: int     max_frames: int     use_audio_in_video: bool+    load_audio_from_video: bool     seconds_per_chunk: float     position_id_per_seconds: int | float @@ -116,7 +122,7 @@ def _get_feat_extract_output_lengths(input_lengths, n_window=50):   @auto_docstring-class Qwen3OmniMoeProcessor(ProcessorMixin):+class Qwen3OmniMoeProcessor(OmniModalProcessorMixin):     valid_processor_kwargs = Qwen3OmniMoeProcessorKwargs      def __init__(@@ -155,6 +161,19 @@ def __call__(         fps = output_kwargs["videos_kwargs"].get("fps", 1.0)         fps = fps if fps is not None else 1.0         n_window = output_kwargs["audio_kwargs"].pop("n_window", 50)+        load_audio_from_video = output_kwargs["videos_kwargs"].pop("load_audio_from_video", False)+        if use_audio_in_video:+            logger.warning(+                "`use_audio_in_video` is depreacted and will be removed in v5.25, use `load_audio_from_video` instead"+            )+            load_audio_from_video = True++        if load_audio_from_video:+            audio, video_to_audio_indices = self._resolve_audio_from_video(+                videos,+                audio=audio,+                audio_from_video_indices=None,+            )          if audio is not None:             audio_inputs = self.feature_extractor(audio, **output_kwargs["audio_kwargs"])@@ -200,7 +219,7 @@ def __call__(             image_grid_thw,             video_grid_thw,             video_second_per_grid=video_second_per_grid,-            use_audio_in_video=use_audio_in_video,+            use_audio_in_video=load_audio_from_video,             position_id_per_seconds=position_id_per_seconds,             seconds_per_chunk=seconds_per_chunk,         )
src/transformers/processing_utils.py468 + / 155 −
@@ -57,6 +57,8 @@     direct_transformers_import,     hf_api,     is_torch_available,+    is_torchcodec_available,+    is_torchcodec_greater_or_equal,     list_repo_templates,     logging,     resolve_revision,@@ -76,6 +78,9 @@ from .video_utils import VideoInput, VideoMetadataType, make_batched_videos  +if is_torchcodec_available():+    import torchcodec+ if is_torch_available():     import torch @@ -731,8 +736,7 @@ def prepare_inputs_layout(             text = list(text).copy()          if audio is not None and self._audio_processor is not None:-            sampling_rate = kwargs.get("sampling_rate", self._audio_processor.sampling_rate)-            audio = self._audio_processor.fetch_audio(audio, sampling_rate=sampling_rate)+            audio = self._audio_processor.fetch_audio(audio, sampling_rate=self._audio_processor.sampling_rate)             audio = make_list_of_audio(audio)          if images is not None and hasattr(self, "image_processor"):@@ -1991,72 +1995,90 @@ def validate_init_kwargs(processor_config, valid_kwargs):          return unused_kwargs, valid_kwargs -    def apply_chat_template(+    def _multimodal_inputs_from_conversations(         self,-        conversation: list[dict[str, str]] | list[list[dict[str, str]]],-        chat_template: str | None = None,-        tools: list[dict] | None = None,-        documents: list[dict[str, str]] | None = None,-        add_generation_prompt: bool = False,-        continue_final_message: bool | str = False,-        return_assistant_tokens_mask: bool = False,-        tokenize: bool = False,-        return_tensors: str | TensorType | None = None,-        return_dict: bool = False,+        conversations: list[list[dict[str, str]]],+        audio_loading_params: dict,         load_audio_from_video: bool = False,-        processor_kwargs: dict | None = None,-        **kwargs,-    ) -> str:+    ):         """-        Similar to the `apply_chat_template` method on tokenizers, this method applies a Jinja template to input-        conversations to turn them into a single tokenizable string.+        Traverse through conversation and extract all provided multimodal data,+        including URL/path or already decoded inputs. Images and video are returned+        in nested format while audio is flattened into a single list for all batches.+        """+        batch_images, batch_videos, batch_audios = [], [], []+        for conversation in conversations:+            images, videos = [], []+            for message in conversation:+                content = message.get("content") or []+                if isinstance(content, str):+                    continue -        The input is expected to be in the following format, where each message content is a list consisting of text and-        optionally image or video inputs. One can also provide an image, video, URL or local path which will be used to form-        `pixel_values` when `return_dict=True`. If not provided, one will get only the formatted text, optionally tokenized text.+                new_content = []  # don't modify user's dict in-place!++                # Single pass so that audio keeps the exact order of the conversation.+                # Audio models do not accept nested list of audios (yet!) so we construct a flat input audio list+                for block, next_block in zip(content, [*content[1:], None]):+                    new_content.append(block)+                    block_type = block["type"]+                    if block_type == "image":+                        images.extend(block[key] for key in ["image", "url", "path", "base64"] if key in block)+                    elif block_type == "video":+                        video_fnames = [block[key] for key in ["video", "url", "path"] if key in block]+                        videos.extend(video_fnames)+                        if load_audio_from_video:+                            batch_audios.extend(load_audio(fname, **audio_loading_params) for fname in video_fnames)+                            # One audio entry per video, right after it, so the template emits the `audio` token in the same spot.+                            # Deprecated since we dont want to dummy-insert, let users choose where to put audio!+                            if next_block != {"type": "audio"}:+                                new_content.append({"type": "audio"})+                                logger.warning_once(+                                    "When setting `load_audio_from_video=True` you must add an empty `audio` entry after each video in the conversation. "+                                    "From v5.25 the entry will NOT be added automatically when calling `processor.apply_chat_template()`"+                                )+                    elif block_type == "audio":+                        audio_fnames = [block[key] for key in ["audio", "url", "path"] if key in block]+                        batch_audios.extend(load_audio(fname, **audio_loading_params) for fname in audio_fnames) -        conversation = [-            {-                "role": "user",-                "content": [-                    {"type": "image", "url": "https://www.ilankelman.org/stopsigns/australia.jpg"},-                    {"type": "text", "text": "Please describe this image in detail."},-                ],-            },-        ]+                message["content"] = new_content -        Args:-            conversation (`Union[list[Dict, [str, str]], list[list[dict[str, str]]]]`):-                The conversation to format.-            chat_template (`Optional[str]`, *optional*):-                The Jinja template to use for formatting the conversation. If not provided, the tokenizer's-                chat template is used.-        """-        processor_kwargs = processor_kwargs or {}+            # Currently all processors can accept nested list of batches, but not flat list of visuals+            # So we'll make a batched list of images and let the processor handle it+            batch_images.append(images)+            batch_videos.append(videos)+        return batch_images, batch_videos, batch_audios, {} +    def _resolve_chat_template(self, chat_template: str | None) -> str:+        """Return the template string to render, resolving defaults and template names."""         if chat_template is None:             if isinstance(self.chat_template, dict) and "default" in self.chat_template:-                chat_template = self.chat_template["default"]-            elif isinstance(self.chat_template, dict):+                return self.chat_template["default"]+            if isinstance(self.chat_template, dict):                 raise ValueError(                     'The processor has multiple chat templates but none of them are named "default". You need to specify'                     " which one to use by passing the `chat_template` argument. Available templates are: "                     f"{', '.join(self.chat_template.keys())}"                 )-            elif self.chat_template is not None:-                chat_template = self.chat_template-            else:-                raise ValueError(-                    "Cannot use apply_chat_template because this processor does not have a chat template."-                )-        else:-            if isinstance(self.chat_template, dict) and chat_template in self.chat_template:-                # It's the name of a template, not a full template string-                chat_template = self.chat_template[chat_template]-            else:-                # It's a template string, render it directly-                pass+            if self.chat_template is not None:+                return self.chat_template+            raise ValueError("Cannot use apply_chat_template because this processor does not have a chat template.") +        if isinstance(self.chat_template, dict) and chat_template in self.chat_template:+            # It's the name of a template, not a full template string+            return self.chat_template[chat_template]+        # It's a template string, render it directly+        return chat_template++    def _prepare_processor_kwargs(+        self,+        chat_template: str,+        processor_kwargs: dict,+        kwargs: dict,+        add_generation_prompt: bool,+        continue_final_message: bool,+        return_assistant_tokens_mask: bool,+    ) -> dict:+        """Handle legacy **kwargs, validate flag combinations, and set offset-mapping options."""         # Users might still be passing processing kwargs in `**kwargs` so we need to filter         # out additional kwargs that the template expects via Jinja2 template introspection         template_kwargs = _get_template_variables(chat_template)@@ -2091,31 +2113,32 @@ def apply_chat_template(                     "`return_assistant_tokens_mask` is not possible with slow tokenizers. Make sure you have `tokenizers` installed. "                     "If the error persists, open an issue to support a Fast tokenizer for your model."                 )-            else:-                processor_kwargs["return_offsets_mapping"] = (-                    True  # force offset mapping so we can infer token boundaries-                )-                processor_kwargs["return_text_replacement_offsets"] = True+            # force offset mapping so we can infer token boundaries+            processor_kwargs["return_offsets_mapping"] = True+            processor_kwargs["return_text_replacement_offsets"] = True++        return processor_kwargs -        # Set the sampling rate to load the audio files if user hasn't already passed with `kwargs`.+    def _resolve_audio_loading_params(self, processor_kwargs: dict) -> dict:+        """Pick the sampling rate and backend used to load audio files."""         audio_kwargs_from_user = processor_kwargs.get("audio_kwargs", {})-        sampling_rate = kwargs.get(-            "sampling_rate", processor_kwargs.get("sampling_rate", audio_kwargs_from_user.get("sampling_rate"))-        )++        sampling_rate = processor_kwargs.get("sampling_rate", audio_kwargs_from_user.get("sampling_rate"))         if sampling_rate is None:             if hasattr(self._audio_processor, "sampling_rate"):                 sampling_rate = self._audio_processor.sampling_rate             else:                 sampling_rate = 16_000 -        load_audio_backend = kwargs.get(-            "load_audio_backend",-            processor_kwargs.get("load_audio_backend", audio_kwargs_from_user.get("load_audio_backend")),-        )-        if load_audio_backend is None:+        backend = processor_kwargs.get("load_audio_backend", audio_kwargs_from_user.get("load_audio_backend"))+        if backend is None:             default_audio_kwargs = self.valid_processor_kwargs._defaults.get("audio_kwargs", {})-            load_audio_backend = default_audio_kwargs.get("load_audio_backend", "auto")+            backend = default_audio_kwargs.get("load_audio_backend", "auto")++        return {"sampling_rate": sampling_rate, "backend": backend} +    def _normalize_conversations(self, conversation) -> tuple[list, bool]:+        """Batch a single conversation if needed and convert OpenAI image_url blocks to HF image blocks."""         if isinstance(conversation, (list, tuple)) and (             isinstance(conversation[0], (list, tuple)) or hasattr(conversation[0], "content")         ):@@ -2128,7 +2151,7 @@ def apply_chat_template(         # Normalize OpenAI-style "image_url" content blocks to HuggingFace-style "image" blocks         # OpenAI format: {"type": "image_url", "image_url": {"url": "..."}}         # HuggingFace format: {"type": "image", "url": "..."}-        for conversation_idx, conversation in enumerate(conversations):+        for conversation in conversations:             for message in conversation:                 if not isinstance(message.get("content"), list):                     continue@@ -2142,58 +2165,94 @@ def apply_chat_template(                         new_content.append(content)                 message["content"] = new_content +        return conversations, is_batched++    def _adjust_assistant_masks_indices(self, processed_dict: dict[str, Any], generation_indices: list[list[int]]):+        assistant_masks = []+        offset_mapping = processed_dict.pop("offset_mapping")+        input_ids = processed_dict["input_ids"]+        # We do some corrections here to ensure the assistant masks aren't+        # misaligned when we expand up image tokens+        replacement_offsets = processed_dict.pop("text_replacement_offsets", None)+        if replacement_offsets is None or len(replacement_offsets) == 0:+            replacement_offsets = [[]] * len(input_ids)+        for i in range(len(input_ids)):+            current_mask = [0] * len(input_ids[i])+            placeholder_ends = [r["span"][1] for r in replacement_offsets[i]]+            chars_gained = [0] + [r["new_span"][1] - r["span"][1] for r in replacement_offsets[i]]+            for span in generation_indices[i]:+                # Shift the span past any placeholders that were expanded before it+                start_char, end_char = (+                    char + chars_gained[bisect.bisect_right(placeholder_ends, char)] for char in span+                )+                # Mask every token overlapping the span. Zero-width tokens (padding, added specials) never+                # match, and a span truncated away simply matches nothing+                for pos, (token_start, token_end) in enumerate(offset_mapping[i]):+                    if token_start < end_char and token_end > start_char:+                        current_mask[pos] = 1+            assistant_masks.append(current_mask)+        processed_dict["assistant_masks"] = assistant_masks+        return processed_dict++    def apply_chat_template(+        self,+        conversation: list[dict[str, str]] | list[list[dict[str, str]]],+        chat_template: str | None = None,+        tools: list[dict] | None = None,+        documents: list[dict[str, str]] | None = None,+        add_generation_prompt: bool = False,+        continue_final_message: bool | str = False,+        return_assistant_tokens_mask: bool = False,+        tokenize: bool = False,+        return_tensors: str | TensorType | None = None,+        return_dict: bool = False,+        load_audio_from_video: bool = False,+        processor_kwargs: dict | None = None,+        **kwargs,+    ) -> str:+        """+        Similar to the `apply_chat_template` method on tokenizers, this method applies a Jinja template to input+        conversations to turn them into a single tokenizable string.++        The input is expected to be in the following format, where each message content is a list consisting of text and+        optionally image or video inputs. One can also provide an image, video, URL or local path which will be used to form+        `pixel_values` when `return_dict=True`. If not provided, one will get only the formatted text, optionally tokenized text.++        conversation = [+            {+                "role": "user",+                "content": [+                    {"type": "image", "url": "https://www.ilankelman.org/stopsigns/australia.jpg"},+                    {"type": "text", "text": "Please describe this image in detail."},+                ],+            },+        ]++        Args:+            conversation (`Union[list[Dict, [str, str]], list[list[dict[str, str]]]]`):+                The conversation to format.+            chat_template (`Optional[str]`, *optional*):+                The Jinja template to use for formatting the conversation. If not provided, the tokenizer's+                chat template is used.+        """+        processor_kwargs = processor_kwargs or {}++        chat_template = self._resolve_chat_template(chat_template)+        processor_kwargs = self._prepare_processor_kwargs(+            chat_template,+            processor_kwargs,+            kwargs,+            add_generation_prompt=add_generation_prompt,+            continue_final_message=continue_final_message,+            return_assistant_tokens_mask=return_assistant_tokens_mask,+        )+        audio_loading_params = self._resolve_audio_loading_params(processor_kwargs)+        conversations, is_batched = self._normalize_conversations(conversation)+         if tokenize:-            batch_images, batch_videos = [], []-            batch_audios = []-            for conversation in conversations:-                images, videos = [], []-                for message in conversation:-                    content = message.get("content") or []-                    if isinstance(content, str):-                        continue-                    visuals = [-                        content_block for content_block in content if content_block["type"] in ["image", "video"]-                    ]-                    audio_fnames = [-                        content_block[key]-                        for content_block in content-                        for key in ["audio", "url", "path"]-                        if key in content_block and content_block["type"] == "audio"-                    ]-                    image_fnames = [-                        vision_info[key]-                        for vision_info in visuals-                        for key in ["image", "url", "path", "base64"]-                        if key in vision_info and vision_info["type"] == "image"-                    ]-                    images.extend(image_fnames)-                    video_fnames = [-                        vision_info[key]-                        for vision_info in visuals-                        for key in ["video", "url", "path"]-                        if key in vision_info and vision_info["type"] == "video"-                    ]-                    videos.extend(video_fnames)--                    # Audio models do not accept nested list of audios (yet!) so we construct a flat input audio list-                    if not load_audio_from_video:-                        for fname in audio_fnames:-                            batch_audios.append(-                                load_audio(fname, sampling_rate=sampling_rate, backend=load_audio_backend)-                            )-                    else:-                        for fname in video_fnames:-                            # This updates the template in-place and adds audio entry-                            # to ensure `audio` token is added by jinja-                            message["content"].append({"type": "audio"})-                            batch_audios.append(-                                load_audio(fname, sampling_rate=sampling_rate, backend=load_audio_backend)-                            )--                # Currently all processors can accept nested list of batches, but not flat list of visuals-                # So we'll make a batched list of images and let the processor handle it-                batch_images.append(images)-                batch_videos.append(videos)+            batch_images, batch_videos, batch_audios, additional_inputs = self._multimodal_inputs_from_conversations(+                conversations, audio_loading_params, load_audio_from_video+            )          # `kwargs` overwrite special tokens if both are present         template_kwargs = {**self.tokenizer.special_tokens_map, **kwargs}@@ -2212,34 +2271,37 @@ def apply_chat_template(             prompt = prompt[0]          if tokenize:-            # Tokenizer's `apply_chat_template` never adds special tokens when tokenizing-            # But processor's `apply_chat_template` didn't have an option to tokenize, so users had to format the prompt-            # and pass it to the processor. Users thus never worried about special tokens relying on processor handling-            # everything internally. The below line is to keep BC for that and be able to work with model that have-            # special tokens in the template (consistent with tokenizers). We dont want to raise warning, it will flood command line-            # without actionable solution for users+            # Tokenizer's `apply_chat_template` never adds special tokens. Processor's `apply_chat_template`+            # originally had no tokenize option, so users formatted the prompt themselves and passed it to the+            # processor, relying on it to handle everything internally (never worrying about special tokens).+            # This keeps BC for that flow and supports templates containing special tokens (consistent with+            # tokenizers). No warning is raised: it would flood the command line with nothing actionable for users.             single_prompt = prompt[0] if is_batched else prompt             if self.tokenizer.bos_token is not None and single_prompt.startswith(self.tokenizer.bos_token):                 processor_kwargs["add_special_tokens"] = False -            # Always sample frames by default unless explicitly set to `False` by users. If users do not pass `num_frames`/`fps`-            # sampling should not done for BC.+            # Always sample frames by default unless explicitly set to `False` by users. If users do not pass+            # `num_frames`/`fps` sampling should not done for BC.             if "do_sample_frames" not in processor_kwargs and (                 processor_kwargs.get("fps") is not None or processor_kwargs.get("num_frames") is not None             ):                 processor_kwargs["do_sample_frames"] = True -            # Set only is user passes a non-None value. Otherwise wa want to use each processor's own defaults+            # Set only if user passes a non-None value. Otherwise we want to use each processor's own defaults             if return_tensors:                 processor_kwargs["return_tensors"] = return_tensors              # Audio was loaded/resampled by us above, so let the audio processor know at which rate.             # (we additionally preserve the location of the kwarg in the nested structure kwargs -> processor -> audio)             if batch_audios:+                audio_kwargs_from_user = processor_kwargs.get("audio_kwargs", {})                 if "sampling_rate" in audio_kwargs_from_user:-                    processor_kwargs["audio_kwargs"] = {**audio_kwargs_from_user, "sampling_rate": sampling_rate}+                    processor_kwargs["audio_kwargs"] = {+                        **audio_kwargs_from_user,+                        "sampling_rate": audio_loading_params["sampling_rate"],+                    }                 else:-                    processor_kwargs["sampling_rate"] = sampling_rate+                    processor_kwargs["sampling_rate"] = audio_loading_params["sampling_rate"]              images_exist = any((im is not None) for im_list in batch_images for im in im_list)             videos_exist = any((vid is not None) for vid_list in batch_videos for vid in vid_list)@@ -2248,35 +2310,13 @@ def apply_chat_template(                 images=batch_images if images_exist else None,                 videos=batch_videos if videos_exist else None,                 audio=batch_audios or None,+                **additional_inputs,                 **processor_kwargs,             )              if return_dict:                 if return_assistant_tokens_mask:-                    assistant_masks = []-                    offset_mapping = out.pop("offset_mapping")-                    input_ids = out["input_ids"]-                    # We do some corrections here to ensure the assistant masks aren't-                    # misaligned when we expand up image tokens-                    replacement_offsets = out.pop("text_replacement_offsets", None)-                    if replacement_offsets is None or len(replacement_offsets) == 0:-                        replacement_offsets = [[]] * len(input_ids)-                    for i in range(len(input_ids)):-                        current_mask = [0] * len(input_ids[i])-                        placeholder_ends = [r["span"][1] for r in replacement_offsets[i]]-                        chars_gained = [0] + [r["new_span"][1] - r["span"][1] for r in replacement_offsets[i]]-                        for span in generation_indices[i]:-                            # Shift the span past any placeholders that were expanded before it-                            start_char, end_char = (-                                char + chars_gained[bisect.bisect_right(placeholder_ends, char)] for char in span-                            )-                            # Mask every token overlapping the span. Zero-width tokens (padding, added specials) never-                            # match, and a span truncated away simply matches nothing-                            for pos, (token_start, token_end) in enumerate(offset_mapping[i]):-                                if token_start < end_char and token_end > start_char:-                                    current_mask[pos] = 1-                        assistant_masks.append(current_mask)-                    out["assistant_masks"] = assistant_masks+                    out = self._adjust_assistant_masks_indices(out, generation_indices)                     out.convert_to_tensors(tensor_type=return_tensors)                 return out             else:@@ -2386,6 +2426,279 @@ def _check_special_mm_tokens(self, text: list[str], text_inputs: "BatchFeature",                     )  +class OmniModalProcessorMixin(ProcessorMixin):+    """+    This is a mixin used to provide additional functionality for omni-modal processor classes,+    including an API to load video and the corresponding audio track. New functions shared by omni+    models should be added here rather than in `ProcessorMixin` to keep simple processors apart from+    more involved models.+    Omni modal models are recommended to use this mixin from now on to get more functionality supported+    for cross-modal interactions.+    """++    @auto_docstring+    def __call__(+        self,+        images: ImageInput | None = None,+        text: TextInput | PreTokenizedInput | list[TextInput] | list[PreTokenizedInput] | None = None,+        videos: VideoInput | None = None,+        audio: AudioInput | None = None,+        audio_from_video_indices: list[int] | None = None,+        **kwargs: Unpack[ProcessingKwargs],+    ):+        r"""+        audio_from_video_indices (`list[int]`, *optional*):+            A list that describes the source of each audio slot where+                - `None` -> standalone audio associated with no video track+                - `i`    -> audio extracted from videos[i]+        """+        images, text, videos, audio = self.prepare_inputs_layout(+            images=images, text=text, videos=videos, audio=audio, **kwargs+        )+        self.validate_inputs(images=images, text=text, videos=videos, audio=audio, **kwargs)++        merged_kwargs = self._merge_kwargs(+            self.valid_processor_kwargs,+            tokenizer_init_kwargs=self.tokenizer.init_kwargs if hasattr(self, "tokenizer") else {},+            **kwargs,+        )++        # TODO @guarin: move it to processor level argument. Used by EG2 and qwen-omni series+        load_audio_from_video = merged_kwargs["videos_kwargs"].get("load_audio_from_video")+        if load_audio_from_video:+            audio, video_to_audio_indices = self._resolve_audio_from_video(+                videos,+                audio=audio,+                audio_from_video_indices=audio_from_video_indices,+            )++        processed_images = processed_videos = processed_audio = {}+        images_replacements = videos_replacements = audio_replacements = []+        if images is not None and hasattr(self, "image_processor"):+            processed_images, images_replacements = self._process_images(images, **merged_kwargs["images_kwargs"])+        if audio is not None and self._audio_processor is not None:+            processed_audio, audio_replacements = self._process_audio(audio, **merged_kwargs["audio_kwargs"])+            if load_audio_from_video:+                audio_replacements = [+                    sample for idx, sample in enumerate(audio_replacements) if idx not in video_to_audio_indices+                ]+        if videos is not None and hasattr(self, "video_processor"):+            if load_audio_from_video:+                additional_kwargs = {+                    "audio": audio,+                    "processed_audio": processed_audio,+                    "audio_kwargs": merged_kwargs["audio_kwargs"],+                    "video_to_audio_indices": video_to_audio_indices,+                }+                processed_videos, videos_replacements = self._process_videos(+                    videos, **merged_kwargs["videos_kwargs"], **additional_kwargs+                )+            else:+                processed_videos, videos_replacements = self._process_videos(videos, **merged_kwargs["videos_kwargs"])++        text_inputs = {}+        return_tensors = merged_kwargs["text_kwargs"].get("return_tensors", None)+        if getattr(self, "tokenizer", None) is not None and text is not None:+            return_mm_token_type_ids = merged_kwargs["text_kwargs"].pop("return_mm_token_type_ids", False)+            return_text_replacement_offsets = merged_kwargs["text_kwargs"].pop(+                "return_text_replacement_offsets", False+            )++            text, text_replacement_offsets = self.get_text_with_replacements(+                text,+                images_replacements,+                videos_replacements,+                audio_replacements,+            )+            text_inputs = self.tokenizer(text, **merged_kwargs["text_kwargs"])+            self._check_special_mm_tokens(text, text_inputs, modalities=["image", "video", "audio"])++            if return_text_replacement_offsets:+                text_inputs["text_replacement_offsets"] = text_replacement_offsets++            if return_mm_token_type_ids:+                text_inputs["mm_token_type_ids"] = self.create_mm_token_type_ids(text_inputs["input_ids"])++        # Pop unused keys from the inputs, e.g. inputs used only to compute number of image tokens+        data = {**text_inputs, **processed_images, **processed_videos, **processed_audio}+        data = {k: v for k, v in data.items() if k not in self.unused_input_names}++        if not kwargs.get("return_metadata"):+            data.pop("video_metadata", None)++        return BatchFeature(data, tensor_type=return_tensors, skip_tensor_conversion=self.skip_tensor_conversion)++    def _resolve_audio_from_video(+        self,+        videos: list,+        audio: list | None = None,+        audio_from_video_indices: list[int | None] | None = None,+    ) -> tuple[list, list[int | None]]:+        """+        Resolve audio inputs and their relationship to input videos+        when `load_audio_from_video=True` and model requires native interleaving.++        `audio_from_video_indices` describes the source of each audio slot:+            - `None` -> standalone audio associated with no video track+            - `i`    -> audio extracted from videos[i]++        Returns:+            `audio (list[str | np.ndarray | torch.Tensor])`:+                Resolved audio inputs, with audio-from-video appended where needed.+                The order of audio will be aligned correctly with the input text, as+                long as users pass valid `audio_from_video_indices` in ambiguous situations+            `video_to_audio_indices (list[int, int])`:+                video_to_audio_indices[i] gives the audio index associated with+                videos[i], or None if the video has no associated audio.+        """+        audio = list(audio or [])+        videos = make_batched_videos(videos)++        if audio_from_video_indices is None:+            if audio:+                raise ValueError(+                    "Mixing `audio` with `load_audio_from_video=True` requires `audio_from_video_indices`."+                )+            audio_from_video_indices = list(range(len(videos)))  # videos only: one audio per video++        if audio_from_video_indices.count(None) != len(audio):+            raise ValueError(+                f"`audio_from_video_indices` has {audio_from_video_indices.count(None)} standalone slots but {len(audio)} audio inputs."+            )++        if not is_torchcodec_greater_or_equal("0.17"):+            raise ValueError("Loading audio track from video file requires torchcodec>=0.17 installed!")++        standalone_audio = iter(audio)+        audio_resolved = []+        video_to_audio = [None] * len(videos)  # reverse mapping, `None` means no audio++        for video_idx in audio_from_video_indices:+            if video_idx is None:+                audio_resolved.append(next(standalone_audio))+                continue++            # Only `torchcodec` can decode audio track from all types of video file formats+            # Don't raise a load-error if video is silent and instead skip it+            cont_metadata = torchcodec.decoders.get_container_metadata(videos[video_idx])+            if not any(stream.media_type == "audio" for stream in cont_metadata.streams):+                continue++            loaded_audio = load_audio(+                videos[video_idx],+                backend="torchcodec",+                sampling_rate=self._audio_processor.sampling_rate,+            )+            video_to_audio[video_idx] = len(audio_resolved)+            audio_resolved.append(loaded_audio)++        # Empty list resolves to `None` since processors cannot handle empty inputs+        audio_resolved = audio_resolved if audio_resolved else None+        return audio_resolved, video_to_audio++    def _process_videos(+        self,+        videos: VideoInput,+        audio: AudioInput | None = None,+        processed_audio: dict | None = None,+        audio_kwargs: dict | None = None,+        video_to_audio_indices: list | None = None,+        **kwargs: Unpack[VideosKwargs],+    ):+        # pop so it is not passed down to video processor+        load_audio_from_video = kwargs.pop("load_audio_from_video", None)+        processed_videos = self.video_processor(videos, **kwargs)++        video_replacements = []+        videos = make_batched_videos(videos)+        for idx in range(len(videos)):+            replacement_text = self.replace_video_token(+                processed_videos,+                video_idx=idx,+                processed_audio=processed_audio,+                load_audio_from_video=load_audio_from_video,+                video_to_audio_indices=video_to_audio_indices,+                **kwargs,+            )+            video_replacements.append(replacement_text)+        return processed_videos, video_replacements++    def replace_video_token(+        self,+        video_inputs: dict,+        video_idx: int,+        audio: AudioInput | None = None,+        processed_audio: dict | None = None,+        audio_kwargs: dict | None = None,+        video_to_audio_indices: list | None = None,+        **kwargs: Unpack[VideosKwargs],+    ) -> str:+        raise NotImplementedError()++    # Opt-in method, only models that support interleaving video-audio can override it+    # Not used by default and should be called explicitly by overriding `_process_video`+    # since models might have diff conditions to apply video+audio interleaved tokens+    def replace_video_with_audio_token(+        self, video_inputs: dict, video_idx: int, audio_inputs: dict, audio_idx: int, **kwargs: VideosKwargs+    ) -> str:+        raise NotImplementedError()++    def _multimodal_inputs_from_conversations(+        self,+        conversations: list[list[dict[str, str]]],+        audio_loading_params: dict,+        load_audio_from_video: bool = False,+    ):+        """+        Traverse through conversation and extract all provided multimodal data,+        including URL/path or already decoded inputs. Images and video are returned+        in nested format while audio is flattened into a single list for all batches.+        If `load_audio_from_video=True`, then each video is linked to audio within+        a batch via `audio_from_video_indices`+        """+        batch_images, batch_videos = [], []+        batch_audios, batch_audio_from_video_indices = [], []+        video_idx = 0+        for conversation in conversations:+            images, videos = [], []+            for message in conversation:+                content = message.get("content") or []+                if isinstance(content, str):+                    continue++                image_fnames = [+                    content_block[key]+                    for content_block in content+                    for key in ["image", "url", "path", "base64"]+                    if key in content_block and content_block["type"] == "image"+                ]+                images.extend(image_fnames)++                # Keep track of audio inputs and whether they comes from video or not+                for content_block in content:+                    if content_block["type"] == "video":+                        video = next(v for k in ("video", "url", "path") if (v := content_block.get(k)) is not None)+                        videos.append(video)+                        if load_audio_from_video:+                            batch_audio_from_video_indices.append(video_idx)++                        video_idx += 1+                    elif content_block["type"] == "audio":+                        audio = next(v for k in ("audio", "url", "path") if (v := content_block.get(k)) is not None)+                        batch_audios.append(load_audio(audio, **audio_loading_params))+                        batch_audio_from_video_indices.append(None)++            # Currently all processors can accept nested list of batches, but not flat list of visuals+            # So we'll make a batched list of images and let the processor handle it+            batch_images.append(images)+            batch_videos.append(videos)+        additional_inputs = {+            "audio_from_video_indices": batch_audio_from_video_indices,+            "load_audio_from_video": load_audio_from_video,+        }+        return batch_images, batch_videos, batch_audios, additional_inputs++ ProcessorMixin.push_to_hub = copy_func(ProcessorMixin.push_to_hub) if ProcessorMixin.push_to_hub.__doc__ is not None:     ProcessorMixin.push_to_hub.__doc__ = ProcessorMixin.push_to_hub.__doc__.format(
src/transformers/testing_utils.py16 + / 0 −
@@ -179,6 +179,7 @@     is_torchao_available,     is_torchaudio_available,     is_torchcodec_available,+    is_torchcodec_greater_or_equal,     is_torchvision_available,     is_triton_available,     is_vision_available,@@ -715,6 +716,21 @@ def decorator(test_case):     return decorator  +def require_torchcodec_greater_or_equal(version: str):+    """+    Decorator marking a test that requires torchcodec version >= `version`.++    These tests are skipped when PyTorch version is less than `version`.+    """++    def decorator(test_case):+        return unittest.skipUnless(+            is_torchcodec_greater_or_equal(version), f"test requires PyTorch version >= {version}"+        )(test_case)++    return decorator++ def require_huggingface_hub_greater_or_equal(version: str):     """     Decorator marking a test that requires huggingface_hub version >= `version`.
tests/models/embedding_gemma2/test_modeling_embedding_gemma2.py59 + / 0 −
@@ -31,6 +31,7 @@     CaptureLogger,     require_torch,     require_torch_accelerator,+    require_torchcodec_greater_or_equal,     slow,     torch_device, )@@ -496,6 +497,7 @@ class EmbeddingGemma2IntegrationTest(MemoryCleanupMixin, unittest.TestCase):     )     AUDIO_URL = "https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/song_1.mp3"     VIDEO_URL = "https://huggingface.co/datasets/hf-internal-testing/fixtures_videos/resolve/main/tennis.mp4"+    SILENT_VIDEO_URL = "https://huggingface.co/datasets/hf-internal-testing/fixtures_videos/resolve/main/karate.mp4"      @classmethod     def setUpClass(cls):@@ -507,6 +509,7 @@ def setUpClass(cls):         cls.image_2 = url_to_local_path(cls.IMAGE_2_URL)         cls.audio = url_to_local_path(cls.AUDIO_URL)         cls.video = url_to_local_path(cls.VIDEO_URL)+        cls.video_silent = url_to_local_path(cls.SILENT_VIDEO_URL)      @staticmethod     def _pool(model, inputs):@@ -705,6 +708,62 @@ def test_model_multiple_modalities(self):         expected = self._load_expected("multiple_modalities")         torch.testing.assert_close(embeddings.cpu(), expected["embeddings"], rtol=self.RTOL, atol=self.ATOL) +    @require_torchcodec_greater_or_equal("0.17.0")+    def test_model_video_and_audio(self):+        """Test that we can load the audio track from video file and interleave them frame by frame."""+        model = EmbeddingGemma2Model.from_pretrained(self.checkpoint_name, device_map=torch_device)++        conversations = [+            [+                {+                    "role": "user",+                    "content": [+                        {"type": "text", "text": "A man practicing karate."},+                        {"type": "video", "url": self.video_silent},+                    ],+                },+            ],+            [+                {+                    "role": "user",+                    "content": [+                        {"type": "text", "text": "A man playing tennis."},+                        {"type": "video", "url": self.video},+                    ],+                },+            ],+            [+                {+                    "role": "user",+                    "content": [+                        {"type": "text", "text": "A song"},+                        {"type": "audio", "url": self.audio},+                    ],+                },+            ],+        ]+        inputs = self.processor.apply_chat_template(+            conversations,+            tokenize=True,+            return_dict=True,+            return_tensors="pt",+            load_audio_from_video=True,+            processor_kwargs={+                "videos_kwargs": {+                    "overflow_strategy": "uniform",+                    "max_frames": 10,+                    "do_sample_frames": True,+                    "add_timestamps": True,+                },+                "audio_kwargs": {"truncation": False},+                "text_kwargs": {"padding": True},+            },+        ).to(model.device)+        embeddings = self._pool(model, inputs)++        expected = self._load_expected("video_and_audio")+        torch.testing.assert_close(embeddings.cpu(), expected["embeddings"], rtol=self.RTOL, atol=self.ATOL)+     def test_model_manual_placeholders(self):         """         Placeholders written in the text interleave the media with it, in the order the media items are given.
tests/models/embedding_gemma2/test_processing_embedding_gemma2.py312 + / 64 −
@@ -18,17 +18,20 @@ import numpy as np  from transformers import EmbeddingGemma2Processor-from transformers.testing_utils import get_tests_dir, require_torch, require_vision+from transformers.testing_utils import require_torch, require_torchcodec_greater_or_equal, require_vision+from transformers.utils import is_torch_available from transformers.video_utils import VideoMetadata -from ...test_processing_common import ProcessorTesterMixin+from ...test_processing_common import ProcessorTesterMixin, url_to_local_path  -SAMPLE_VOCAB = get_tests_dir("fixtures/test_sentencepiece.model")+if is_torch_available():+    import torch   @require_vision class EmbeddingGemma2ProcessorTest(ProcessorTesterMixin, unittest.TestCase):+    model_id = "google/embeddinggemma-2"     processor_class = EmbeddingGemma2Processor     videos_unstructured_max_length = 570     videos_text_kwargs_max_length = 570@@ -51,11 +54,6 @@ def _setup_video_processor(cls):         }         return video_processor_class(**video_processor_kwargs) -    @classmethod-    def _setup_feature_extractor(cls):-        feature_extractor_class = cls._get_component_class_from_processor("feature_extractor")-        return feature_extractor_class()-     @classmethod     def _setup_image_processor(cls):         image_processor_class = cls._get_component_class_from_processor("image_processor")@@ -66,63 +64,23 @@ def _setup_image_processor(cls):         }         return image_processor_class(**image_processor_kwargs) -    @classmethod-    def _setup_tokenizer(cls):-        tokenizer_class = cls._get_component_class_from_processor("tokenizer")-        extra_special_tokens = {-            "image_token": "<|image|>",-            "video_token": "<|video|>",-            "boi_token": "<start_of_image>",-            "eoi_token": "<end_of_image>",-            "audio_token": "<|audio|>",-            "boa_token": "<start_of_audio>",-            "eoa_token": "<end_of_audio>",-        }-        tokenizer = tokenizer_class.from_pretrained(-            SAMPLE_VOCAB, keep_accents=True, extra_special_tokens=extra_special_tokens-        )-        tokenizer.pad_token_id = tokenizer.eos_token_id-        return tokenizer-     @classmethod     def tearDownClass(cls):         shutil.rmtree(cls.tmpdirname, ignore_errors=True) -    # TODO: remove `_CHAT_TEMPLATE` and set `model_id` once the checkpoint is available on the Hub.-    _CHAT_TEMPLATE = (-        "{%- for msg in messages if msg.get('role') == 'system' -%}"-        "{%- if msg.get('content') is string -%}"-        "{{ msg['content'] }}"-        "{%- else -%}"-        "{%- for item in msg['content'] if item.get('type') == 'text' -%}"-        "{{ item['text'] }}"-        "{%- endfor -%}"-        "{%- endif -%}"-        "{%- endfor -%}"-        "{%- for msg in messages if msg.get('role') != 'system' -%}"-        "{%- if msg.get('content') is string -%}"-        "{{ msg['content'] }}"-        "{%- else -%}"-        "{%- set existing_text = msg['content'] | selectattr('type', 'equalto', 'text') | map(attribute='text') | join -%}"-        "{%- set has_manual_placeholders = ('<|image|>' in existing_text) or ('<|video|>' in existing_text) or ('<|audio|>' in existing_text) -%}"-        "{%- for item in msg['content'] -%}"-        "{%- if item.get('type') == 'text' -%}"-        "{{ item['text'] }}"-        "{%- elif not has_manual_placeholders and item.get('type') == 'image' -%}"-        "<|image|>"-        "{%- elif not has_manual_placeholders and item.get('type') == 'video' -%}"-        "<|video|>"-        "{%- elif not has_manual_placeholders and item.get('type') == 'audio' -%}"-        "<|audio|>"-        "{%- endif -%}"-        "{%- endfor -%}"-        "{%- endif -%}"-        "{%- endfor -%}"-    )-     @staticmethod     def prepare_processor_dict():-        return {"image_seq_length": 3}+        return {"image_seq_length": 3, "audio_seq_length": 280}++    @property+    def video_sampling_expectations(self):+        return [+            {"max_frames": 3, "fps": None, "expected_dim": 0, "output_length": 3},+            {"overflow_strategy": None, "max_frames": None, "fps": 10, "expected_dim": 0, "output_length": 3},+            {"do_sample_frames": False, "fps": 10, "expected_dim": 0, "output_length": 11},+            {"do_sample_frames": False, "expected_dim": 0, "output_length": 11},+            {"expected_dim": 0, "output_length": 1},+        ]      # Override as EmbeddingGemma 2 needs images to be an explicitly nested batch     def prepare_images_inputs(self, batch_size: int | None = None):@@ -264,15 +222,15 @@ def test_multimodal_and_nested_inputs_without_text(self):         aud3 = np.zeros(1600, dtype=np.float32)          # 1. Nested audio list: batch size 2, sample 0 has 2 audios, sample 1 has 1 audio-        out_aud = processor(audio=[[aud1, aud2], [aud3]], return_tensors="pt")+        out_aud = processor(audio=[[aud1, aud2], [aud3]], padding=True, return_tensors="pt")         self.assertEqual(out_aud["input_ids"].shape[0], 2)         self.assertGreater(             (out_aud["input_ids"][0] == processor.audio_token_id).sum().item(),             (out_aud["input_ids"][1] == processor.audio_token_id).sum().item(),         )          # 2. Nested video list: batch size 2, sample 0 has 2 videos, sample 1 has 1 video-        out_vid = processor(videos=[[vid1, vid2], [vid3]], do_sample_frames=False, return_tensors="pt")+        out_vid = processor(videos=[[vid1, vid2], [vid3]], padding=True, do_sample_frames=False, return_tensors="pt")         self.assertEqual(out_vid["input_ids"].shape[0], 2)         self.assertGreater(             (out_vid["input_ids"][0] == processor.video_token_id).sum().item(),@@ -283,6 +241,7 @@ def test_multimodal_and_nested_inputs_without_text(self):         out_mixed = processor(             images=[[img1, img2], [img3]],             audio=[[aud1], [aud2, aud3]],+            padding=True,             return_tensors="pt",         )         self.assertEqual(out_mixed["input_ids"].shape[0], 2)@@ -293,18 +252,19 @@ def test_multimodal_and_nested_inputs_without_text(self):          # 4. Mismatched outer batch sizes across modalities with text=None raises ValueError         with self.assertRaisesRegex(ValueError, "inconsistently sized modality batches"):-            processor(images=[[img1], [img2]], audio=[aud1], return_tensors="pt")+            processor(images=[[img1], [img2]], audio=[aud1], padding=True, return_tensors="pt")          # 5. Nested audio and video lists also work when explicit text placeholders are provided         out_aud_with_text = processor(-            text=["<|audio|> <|audio|>", "<|audio|>"], audio=[[aud1, aud2], [aud3]], return_tensors="pt"+            text=["<|audio|> <|audio|>", "<|audio|>"], audio=[[aud1, aud2], [aud3]], padding=True, return_tensors="pt"         )         self.assertTrue((out_aud["input_ids"] == out_aud_with_text["input_ids"]).all())          out_vid_with_text = processor(             text=["<|video|> <|video|>", "<|video|>"],             videos=[[vid1, vid2], [vid3]],             do_sample_frames=False,+            padding=True,             return_tensors="pt",         )         self.assertTrue((out_vid["input_ids"] == out_vid_with_text["input_ids"]).all())@@ -374,7 +334,6 @@ def test_validate_inputs_multimodal_placeholder_counts(self):     def test_chat_template_ordering_and_manual_placeholders(self):         """System prompts precede media, content order is preserved, and manual markers disable auto-insertion."""         processor = self.get_processor()-        processor.chat_template = self._CHAT_TEMPLATE         img = np.random.randint(0, 256, size=(56, 56, 3), dtype=np.uint8)         aud = np.zeros(1600, dtype=np.float32) @@ -442,3 +401,292 @@ def test_chat_template_ordering_and_manual_placeholders(self):         self.assertNotIn("<|audio|>", rendered_partial)         with self.assertRaisesRegex(ValueError, "audio"):             processor(text=[rendered_partial], images=[[img]], audio=[aud], return_tensors="pt")++    @require_torchcodec_greater_or_equal("0.17.0")+    def test_chat_template_audio_from_video(self):+        processor = self.get_processor()+        video_file = url_to_local_path(+            "https://huggingface.co/datasets/hf-internal-testing/test-videos/resolve/main/sample_demo_1_320x240.mp4"+        )+        message_video = [+            {+                "role": "user",+                "content": [+                    {"type": "video", "path": video_file},+                    {"type": "text", "text": "Which of these animals is making the sound?"},+                ],+            },+            {+                "role": "assistant",+                "content": [{"type": "text", "text": "It is a cow."}],+            },+            {+                "role": "user",+                "content": [+                    {"type": "text", "text": "Tell me all about this animal."},+                ],+            },+        ]++        audio_file = url_to_local_path(+            "https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/f2641_0_throatclearing.wav"+        )+        message_audio = [+            {+                "role": "user",+                "content": [+                    {"type": "audio", "path": audio_file},+                    {"type": "text", "text": "Which can you hear?"},+                ],+            },+        ]++        silent_video_file = url_to_local_path(+            "https://huggingface.co/datasets/hf-internal-testing/test-videos/resolve/main/karate.mp4"+        )+        message_silent_video = [+            {+                "role": "user",+                "content": [+                    {"type": "video", "path": silent_video_file},+                    {"type": "text", "text": "Describe the man"},+                ],+            },+        ]++        out_dict = processor.apply_chat_template(+            [message_silent_video, message_audio, message_video],+            add_generation_prompt=True,+            tokenize=True,+            return_dict=True,+            return_tensors="pt",+            padding=True,+            truncation=False,+            load_audio_from_video=True,+            # video sampled to 3 frames+            overflow_strategy="uniform",+            max_frames=3,+            do_sample_frames=True,+            add_timestamps=True,+            load_audio_backend="torchcodec",+        )+        self.assertTrue(self.audio_input_name in out_dict)+        self.assertTrue(self.videos_input_name in out_dict)++        self.assertEqual(out_dict["input_ids"].shape[-1], 229)+        self.assertListEqual(list(out_dict[self.audio_input_name].shape[:2]), [2, 290])+        self.assertListEqual(list(out_dict[self.videos_input_name].shape[:2]), [4, 630])++        # Audio can be truncated shorter than video+        out_dict = processor.apply_chat_template(+            [message_silent_video, message_audio],+            add_generation_prompt=True,+            tokenize=True,+            return_dict=True,+            return_tensors="pt",+            padding=True,+            truncation=True,+            max_length=16_000,+            load_audio_from_video=True,+            # video sampled to 3 frames+            overflow_strategy="uniform",+            max_frames=3,+            do_sample_frames=True,+            add_timestamps=True,+            load_audio_backend="torchcodec",+        )++        self.assertEqual(out_dict["input_ids"].shape[-1], 229)+        self.assertListEqual(list(out_dict[self.audio_input_name].shape[:2]), [1, 99])+        self.assertListEqual(list(out_dict[self.videos_input_name].shape[:2]), [3, 630])++        with self.assertRaisesRegex(ValueError, "`audio_from_video_indices` has 2 standalone slots"):+            processor(+                videos=[video_file, silent_video_file],+                audio=[audio_file],+                text=f"{self.video_token}{self.audio_token}{self.video_token}",+                padding=True,+                audio_from_video_indices=[0, None, None],+                return_tensors="pt",+                load_audio_from_video=True,+                overflow_strategy="uniform",+                max_frames=3,+                do_sample_frames=True,+                add_timestamps=True,+                load_audio_backend="torchcodec",+            )++    def _test_apply_chat_template(+        self,+        modality: str,+        batch_size: int,+        return_tensors: str,+        input_name: str,+        processor_name: str,+        input_data: list[str],+    ):+        # override - EG2 rejects `num_frames` if passed to sample video, must use different args+        processor = self.get_processor()+        if processor.chat_template is None:+            self.skipTest("Processor has no chat template")++        if processor_name not in self.processor_class.get_attributes():+            self.skipTest(f"{processor_name} attribute not present in {self.processor_class}")++        # some models have only Fast image processor+        if getattr(processor, processor_name).__class__.__name__.endswith("Fast"):+            return_tensors = "pt"++        batch_messages = [+            [+                {"role": "system", "content": [{"type": "text", "text": "You are a helpful assistant."}]},+                {"role": "user", "content": [{"type": "text", "text": "Describe this."}]},+            ]+        ] * batch_size++        # Test that jinja can be applied+        formatted_prompt = processor.apply_chat_template(batch_messages, add_generation_prompt=True, tokenize=False)+        self.assertEqual(len(formatted_prompt), batch_size)++        # Test that tokenizing with template and directly with `self.tokenizer` gives same output+        formatted_prompt_tokenized = processor.apply_chat_template(+            batch_messages, add_generation_prompt=True, tokenize=True, return_tensors=return_tensors+        )+        add_special_tokens = True+        if processor.tokenizer.bos_token is not None and formatted_prompt[0].startswith(processor.tokenizer.bos_token):+            add_special_tokens = False+        tok_output = processor.tokenizer(+            formatted_prompt, return_tensors=return_tensors, add_special_tokens=add_special_tokens+        )+        expected_output = tok_output.input_ids+        self.assertListEqual(expected_output.tolist(), formatted_prompt_tokenized.tolist())++        # Test that kwargs passed to processor's `__call__` are actually used+        tokenized_prompt_100 = processor.apply_chat_template(+            batch_messages,+            add_generation_prompt=True,+            tokenize=True,+            return_tensors=return_tensors,+            processor_kwargs={+                "padding": "max_length",+                "truncation": True,+                "max_length": self.chat_template_max_length,+            },+        )+        self.assertEqual(len(tokenized_prompt_100[0]), self.chat_template_max_length)++        # Test that `return_dict=True` returns text related inputs in the dict+        out_dict_text = processor.apply_chat_template(+            batch_messages,+            add_generation_prompt=True,+            tokenize=True,+            return_dict=True,+            return_tensors=return_tensors,+        )+        self.assertTrue(all(key in out_dict_text for key in ["input_ids", "attention_mask"]))+        self.assertEqual(len(out_dict_text["input_ids"]), batch_size)+        self.assertEqual(len(out_dict_text["attention_mask"]), batch_size)++        # Test that with modality URLs and `return_dict=True`, we get modality inputs in the dict+        for idx, url in enumerate(input_data[:batch_size]):+            batch_messages[idx][1]["content"] = [batch_messages[idx][1]["content"][0], {"type": modality, "url": url}]++        out_dict = processor.apply_chat_template(+            batch_messages,+            add_generation_prompt=True,+            tokenize=True,+            return_dict=True,+            return_tensors=return_tensors,+            # No more than 2 frames and explicitly disable other ways to sample+            processor_kwargs={"overflow_strategy": "uniform", "max_frames": 2, "fps": None},+        )+        input_name = getattr(self, input_name)+        self.assertTrue(input_name in out_dict)+        self.assertEqual(len(out_dict["input_ids"]), batch_size)+        self.assertEqual(len(out_dict["attention_mask"]), batch_size)++        # Qwen-style pixels don't scale with bs same way as other models+        # calculate expected video token count based on video_grid_thw+        if (grid_thw := out_dict.get(f"{modality}_grid_thw")) is not None:+            mm_len = sum(thw[0] * thw[1] * thw[2] for thw in grid_thw)+        elif (num_frames_per_video := out_dict.get("num_frames_per_video")) is not None:+            # Models that concatenate the frames of every video along a single axis+            mm_len = sum(num_frames_per_video)+        else:+            mm_len = batch_size++        self.assertEqual(len(out_dict[input_name]), mm_len)++        return_tensor_to_type = {"pt": torch.Tensor, "np": np.ndarray, None: list}+        for k in out_dict:+            self.assertIsInstance(out_dict[k], return_tensor_to_type[return_tensors])++        # Test continue from final message+        assistant_message = {+            "role": "assistant",+            "content": [{"type": "text", "text": "It is the sound of"}],+        }+        for idx, url in enumerate(input_data[:batch_size]):+            batch_messages[idx] = batch_messages[idx] + [assistant_message]+        continue_prompt = processor.apply_chat_template(batch_messages, continue_final_message=True, tokenize=False)+        for prompt in continue_prompt:+            self.assertTrue(prompt.endswith("It is the sound of"))  # no `eos` token at the end++    @unittest.skip("Fails on runners only - i suppose it is some torchcodec version thingy, skip for now")+    def test_apply_chat_template_video_frame_sampling(self):+        # override - model doesn\t raise an error and doesn't+        # accept image frame URLs as one video+        processor = self.get_processor()+        messages = [+            [+                {+                    "role": "user",+                    "content": [+                        {+                            "type": "video",+                            "url": url_to_local_path(+                                "https://huggingface.co/datasets/hf-internal-testing/test-videos/resolve/main/tiny_video_320x240.mp4"+                            ),+                        },+                        {"type": "text", "text": "What is shown in this video?"},+                    ],+                },+            ]+        ]++        for processor_kwargs in self.video_sampling_expectations:+            exp_output_length = processor_kwargs.pop("output_length")+            expected_dim = processor_kwargs.pop("expected_dim")++            out_dict_with_video = processor.apply_chat_template(+                messages,+                add_generation_prompt=True,+                tokenize=True,+                return_dict=True,+                return_tensors="pt",+                processor_kwargs=processor_kwargs,+            )+            self.assertTrue(self.videos_input_name in out_dict_with_video)+            self.assertEqual(out_dict_with_video[self.videos_input_name].shape[expected_dim], exp_output_length)++        messages[0][0]["content"][0] = {+            "type": "video",+            "url": [+                url_to_local_path(+                    "https://huggingface.co/datasets/hf-internal-testing/fixtures_image_utils/resolve/main/australia.jpg"+                )+            ]+            * 2,+        }++        # Load video as a list of frames (i.e. images).+        # NOTE: each frame should have same size because we assume they come from one video+        out_dict_with_video = processor.apply_chat_template(+            messages,+            add_generation_prompt=True,+            tokenize=True,+            return_dict=True,+            return_tensors="pt",+            processor_kwargs={"do_sample_frames": False},+        )+        self.assertTrue(self.videos_input_name in out_dict_with_video)
tests/models/qwen2_5_omni/test_processing_qwen2_5_omni.py2 + / 4 −
@@ -23,10 +23,9 @@     Qwen2_5OmniProcessor, ) from transformers.testing_utils import (-    require_librosa,     require_torch,     require_torchaudio,-    require_torchcodec,+    require_torchcodec_greater_or_equal,     require_torchvision,     require_vision, )@@ -167,8 +166,7 @@ def test_post_process_multimodal_output_single_audio(self):         self.assertEqual(len(audio_outputs), 1)         self.assertTrue(np.array_equal(audio_outputs[0], np.arange(6, dtype=np.float32))) -    @require_librosa-    @require_torchcodec+    @require_torchcodec_greater_or_equal("0.17.0")     def test_chat_template_audio_from_video(self):         processor = self.get_processor()         if processor.chat_template is None:
tests/models/qwen3_omni_moe/test_processing_qwen3_omni_moe.py2 + / 4 −
@@ -21,10 +21,9 @@  from transformers import Qwen3OmniMoeProcessor from transformers.testing_utils import (-    require_librosa,     require_torch,     require_torchaudio,-    require_torchcodec,+    require_torchcodec_greater_or_equal,     require_torchvision,     require_vision, )@@ -164,8 +163,7 @@ def test_post_process_multimodal_output_single_audio(self):         self.assertEqual(len(audio_outputs), 1)         self.assertTrue(np.array_equal(audio_outputs[0], np.arange(6, dtype=np.float32))) -    @require_librosa-    @require_torchcodec+    @require_torchcodec_greater_or_equal("0.17.0")     def test_chat_template_audio_from_video(self):         processor = self.get_processor()         if processor.chat_template is None:
tests/test_processing_common.py47 + / 23 −
@@ -24,10 +24,8 @@ from unittest.mock import patch  import numpy as np-from huggingface_hub import hf_hub_download from parameterized import parameterized -from transformers import ProcessorMixin from transformers.processing_utils import MODALITY_TO_AUTOPROCESSOR_MAPPING from transformers.testing_utils import (     check_json_file_has_correct_format,@@ -1489,34 +1487,32 @@ def test_apply_chat_template_video_frame_sampling(self):                 processor_kwargs={"do_sample_frames": True},             ) -    @require_librosa     @require_torchcodec     def test_chat_template_audio_from_video(self):         processor = self.get_processor()         if processor.chat_template is None:             self.skipTest("Processor has no chat template") -        signature = inspect.signature(processor.__call__)-        if "videos" not in {*signature.parameters.keys()} or (-            signature.parameters.get("videos") is not None-            and signature.parameters["videos"].annotation == inspect._empty-        ):-            self.skipTest(f"{self.processor_class} does not support video inputs")+        if "video_processor" not in self.processor_class.get_attributes():+            self.skipTest(f"{self.processor_class.__name__} does not support video inputs")          if (             "feature_extractor" not in self.processor_class.get_attributes()-            or "audio_processor" not in self.processor_class.get_attributes()+            and "audio_processor" not in self.processor_class.get_attributes()         ):-            self.skipTest(f"feature_extractor attribute not present in {self.processor_class}")+            self.skipTest(f"{self.processor_class.__name__} does not support audio inputs") -        video_file_path = hf_hub_download(-            repo_id="hf-internal-testing/test-videos", filename="sample_demo_1_320x240.mp4", repo_type="dataset"+        video_file_path = url_to_local_path(+            "https://huggingface.co/datasets/hf-internal-testing/test-videos/resolve/main/sample_demo_1_320x240.mp4"         )-        messages = [+        video_expected_shape = processor.video_processor(videos=video_file_path)[self.videos_input_name].shape++        Video_audio_message = [             {                 "role": "user",                 "content": [                     {"type": "video", "path": video_file_path},+                    {"type": "audio"},                     {"type": "text", "text": "Which of these animals is making the sound?"},                 ],             },@@ -1532,25 +1528,52 @@ def test_chat_template_audio_from_video(self):             },         ] -        formatted_prompt = processor.apply_chat_template([messages], add_generation_prompt=True, tokenize=False)-        self.assertEqual(len(formatted_prompt), 1)  # batch size=1+        out_dict = processor.apply_chat_template(+            Video_audio_message,+            add_generation_prompt=True,+            tokenize=True,+            return_dict=True,+            return_tensors="pt",+            load_audio_backend="torchcodec",+            load_audio_from_video=True,+        )+        self.assertTrue(self.audio_input_name in out_dict)+        self.assertTrue(self.videos_input_name in out_dict)++        self.assertEqual(out_dict["input_ids"].shape[0], 1)  # batch-size=1+        self.assertEqual(out_dict["attention_mask"].shape[0], 1)  # batch-size=1+        self.assertEqual(out_dict[self.audio_input_name].shape[0], 1)  # 1 audio from video+        self.assertListEqual(list(out_dict[self.videos_input_name].shape), list(video_expected_shape)) +        audio_file_path = url_to_local_path(+            "https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/glass-breaking-151256.mp3"+        )+        audio_message = [+            {+                "role": "user",+                "content": [+                    {"type": "audio", "path": audio_file_path},+                    {"type": "text", "text": "What do you hear in this track?"},+                ],+            },+        ]         out_dict = processor.apply_chat_template(-            messages,+            [Video_audio_message, audio_message],             add_generation_prompt=True,             tokenize=True,             return_dict=True,             return_tensors="pt",+            padding=True,+            load_audio_backend="torchcodec",             load_audio_from_video=True,         )         self.assertTrue(self.audio_input_name in out_dict)         self.assertTrue(self.videos_input_name in out_dict) -        # should always have input_ids and attention_mask-        self.assertEqual(len(out_dict["input_ids"]), 1)  # batch-size=1-        self.assertEqual(len(out_dict["attention_mask"]), 1)  # batch-size=1-        self.assertEqual(len(out_dict[self.audio_input_name]), 1)  # 1 audio in the conversation-        self.assertEqual(len(out_dict[self.videos_input_name]), 1)  # 1 video in the conversation+        self.assertEqual(out_dict["input_ids"].shape[0], 2)+        self.assertEqual(out_dict["attention_mask"].shape[0], 2)+        self.assertEqual(out_dict[self.audio_input_name].shape[0], 2)  # 2 audios+        self.assertListEqual(list(out_dict[self.videos_input_name].shape), list(video_expected_shape))      def test_chat_template_jinja_kwargs(self):         """Tests that users can pass any kwargs and they will be used in jinja templates."""@@ -1802,8 +1825,9 @@ def does_processor_return_mm_offsets(processor_class, method_name: str):         Updated processor need to override a `replace_xxx_method` which is the only         reliable way to tell if processor is old format or not.         """+        parent_class = processor_class.__mro__[1]         child_method = getattr(processor_class, method_name)-        base_method = getattr(ProcessorMixin, method_name)+        base_method = getattr(parent_class, method_name)         return child_method is not base_method      def test_replacement_offsets(self):
utils/check_auto.py20 + / 13 −
@@ -19,7 +19,7 @@ import os import subprocess import tempfile-from collections import Counter, OrderedDict+from collections import Counter, OrderedDict, defaultdict from typing import Any  from sort_auto_mappings import sort_auto_mapping@@ -181,35 +181,42 @@ def get_all_config_mappings(): def get_all_processor_mappings(config_mapping: dict[str, str]):     files_to_names = {         "image_processing_pil": (-            "PilBackend",+            ("PilBackend",),             "IMAGE_PROCESSOR_MAPPING_NAMES_PIL",             MISSING_IMAGE_PROCESSOR_MAPPING_NAMES,         ),         "image_processing": (-            "TorchvisionBackend",+            ("TorchvisionBackend",),             "IMAGE_PROCESSOR_MAPPING_NAMES_TV",             MISSING_IMAGE_PROCESSOR_MAPPING_NAMES,         ),         "video_processing": (-            "BaseVideoProcessor",+            ("BaseVideoProcessor",),             "VIDEO_PROCESSOR_MAPPING_NAMES",             MISSING_VIDEO_PROCESSOR_MAPPING_NAMES,         ),         "feature_extraction": (-            "SequenceFeatureExtractor",+            ("SequenceFeatureExtractor",),             "FEATURE_EXTRACTOR_MAPPING_NAMES",             MISSING_FEATURE_EXTRACTOR_MAPPING_NAMES,         ),-        "processing": ("ProcessorMixin", "PROCESSOR_MAPPING_NAMES", MISSING_PROCESSOR_MAPPING_NAMES),+        "processing": (+            ("ProcessorMixin", "OmniModalProcessorMixin"),+            "PROCESSOR_MAPPING_NAMES",+            MISSING_PROCESSOR_MAPPING_NAMES,+        ),     } -    all_mappings = {}-    for processor_filename, (parent_class_name, mapping_name, missing_mapping_names) in files_to_names.items():-        all_mappings[mapping_name] = build_processor_mapping(-            config_mapping=config_mapping,-            processor_filename=processor_filename,-            parent_class_name=parent_class_name,-        )+    all_mappings = defaultdict(dict)+    for processor_filename, (parent_class_names, mapping_name, missing_mapping_names) in files_to_names.items():+        for parent_class_name in parent_class_names:+            all_mappings[mapping_name].update(+                build_processor_mapping(+                    config_mapping=config_mapping,+                    processor_filename=processor_filename,+                    parent_class_name=parent_class_name,+                )+            )          # Make sure users aren't duplicating the same keys manually         # Skip image processor until pil <-> tv backend are merged into one mapping
utils/fetch_hub_objects_for_ci.py1 + / 1 −
@@ -91,7 +91,7 @@     "https://huggingface.co/datasets/hf-internal-testing/fixtures_image_utils/resolve/main/two_dogs.jpg",     "https://huggingface.co/datasets/hf-internal-testing/fixtures_image_utils/resolve/main/australia.jpg",     "https://huggingface.co/datasets/hf-internal-testing/fixtures_videos/resolve/main/tiny_video.mp4",-    "https://huggingface.co/datasets/hf-internal-testing/fixtures_videos/resolve/main/tiny_video.mp4",+    "https://huggingface.co/datasets/hf-internal-testing/fixtures_videos/resolve/main/karate.mp4",     "https://huggingface.co/datasets/hf-internal-testing/fixtures_image_utils/resolve/main/pipeline-cat-chonk.jpeg",     # we should rely on this single dataset for our tests     "https://huggingface.co/datasets/hf-internal-testing/dummy-audio-samples/resolve/main/bcn_weather.mp3",
utils/modular_model_converter.py2 + / 2 −
@@ -947,8 +947,8 @@ def common_partial_suffix(str1: str, str2: str) -> str:             common_suffix = str1[-i] + common_suffix         else:             break-    # We do not allow full string suffix-    if common_suffix == str1 or common_suffix == str2:+    # We do not allow full string suffix except for `OmniModalProcessorMixin`+    if (common_suffix == str1 or common_suffix == str2) and "ProcessorMixin" not in common_suffix:         common_suffix = ""     return common_suffix