Repository navigation
Audio from video in jinja #49382
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
zucchini-nlp
wants to merge
22
commits into
huggingface:main
Choose a base branch
from
zucchini-nlp:audio-from-video
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,303
−343
Open
Audio from video in jinja #49382
Changes from 17 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
c2b4ee5
deorecate and ley users decicde where to add
zucchini-nlp eb1d7ce
let both be passed but order is wrong yet
zucchini-nlp 03b33cc
move it all over here
zucchini-nlp 3640528
nit
zucchini-nlp 3ce18d8
split it into overridable fn
zucchini-nlp 91d5e88
fix the order
zucchini-nlp a3dff7b
CI repo consistency
zucchini-nlp 3032525
modular fix and revert bad rebase
zucchini-nlp 6f61b65
whta?
zucchini-nlp 7f29f93
eg2 modular and tests
zucchini-nlp 082ae5c
actually fixing it might breaks other models...
zucchini-nlp a9052fa
some comments and BOA/EOA
zucchini-nlp ff6bf21
nits
zucchini-nlp b3950cc
henrique's big comment
zucchini-nlp b55aa03
Merge remote-tracking branch 'upstream/main' into audio-from-video
zucchini-nlp 20e0a3d
fix repo
zucchini-nlp ca68623
fix some tests, maybe need `@require_torchcodec_greater` now :(
zucchini-nlp 94923ec
fix CI i hope
zucchini-nlp e430154
dont allow old qwen - we need to completely override processor one day
zucchini-nlp 1b8574d
skip this one
zucchini-nlp fe7cb0c
add bucket
zucchini-nlp f53dee4
very very weird, but runners dont have latest torchcodec yet, maybe t…
zucchini-nlp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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): | ||
|
|
@@ -841,21 +844,15 @@ def sample_frames( | |
|
|
||
|
|
||
| class EmbeddingGemma2ProcessorKwargs(Gemma4ProcessorKwargs): | ||
| images_kwargs = AttributeError() | ||
|
|
||
| _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): | ||
|
|
@@ -870,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. | ||
|
|
@@ -889,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 | ||
| ) | ||
|
|
||
|
|
@@ -931,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: | ||
|
|
@@ -987,32 +984,182 @@ 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) -> str: | ||
| 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, audio_tokens, vision_tokens, mm:ss, audio_tokens, vision_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 = [] | ||
| processed_audio_tokens = 0 | ||
| for i, frame_sec in enumerate(frame_seconds): | ||
| # vision frame block, labeled with its own (rounded) timestamp | ||
| 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 | ||
| if i < num_frames - 1: | ||
| audio_seconds = range(frame_sec, frame_seconds[i + 1]) | ||
| else: | ||
| audio_seconds = range(frame_sec, total_seconds) | ||
|
|
||
| for sec in audio_seconds: | ||
| n_tokens = num_audio_tokens_per_sec | ||
|
|
||
| # Clamp to account for accumulated rounding/truncation error | ||
| n_tokens = min(n_tokens, num_audio_tokens_total - processed_audio_tokens) | ||
|
|
||
| # The very last audio chunk absorbs any leftover tokens from rounding/sscp | ||
| if i == num_frames - 1 and sec == audio_seconds[-1]: | ||
| n_tokens += max(0, num_audio_tokens_total - processed_audio_tokens - n_tokens) | ||
|
|
||
| if n_tokens > 0: | ||
| segments.append( | ||
| f"{self.timestamp_fmt(sec)} {self.boa_token}{self.audio_token * n_tokens}{self.eoa_token}" | ||
| ) | ||
| processed_audio_tokens += n_tokens | ||
|
|
||
| return " ".join(segments) | ||
|
|
||
| def replace_video_token( | ||
| self, | ||
| video_inputs: dict, | ||
| video_idx: int, | ||
|
guarin marked this conversation as resolved.
|
||
| 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 | ||
| # Round (not floor): at fractional fps (e.g. 29.97) sampled timestamps land just below the nominal second | ||
| frame_seconds = [round(s) for s in metadata.timestamps] | ||
| 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 frame_seconds | ||
| ] | ||
| ) | ||
|
|
||
| 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 | ||
|
Comment on lines
+1149
to
+1150
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. lets add a note that we should change this for future additions not on you but good to raise awareness cc @eustlb Meaning that we do not hardcode values |
||
| 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", | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I explored a bit this part and there might be an issue, LMK if I'm wrong. since we round, say at fps=2 on a 10s video the last range will be (9.5, 10) rounded so (10, 10) which is empty. in this case at the end there will be some leftover audio tokens since their range is none.
so they would need to be added before the segments are joined, like