From cd9335b3888b6bfc5dd5b563fe5f6ec3b4697bd8 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:06:38 +0800 Subject: [PATCH 1/2] feat: add Gemini text-to-speech service --- docs/source/api.rst | 4 + docs/source/services.rst | 38 ++++ examples/voiceover-demo.py | 202 ++++++++++----------- pyproject.toml | 4 + src/manim_voiceover/modify_audio.py | 24 ++- src/manim_voiceover/services/gemini.py | 240 +++++++++++++++++++++++++ tests/test_edge_coverage.py | 132 +++++++++++++- tests/test_service_adapters.py | 125 +++++++++++++ uv.lock | 79 +++++++- 9 files changed, 734 insertions(+), 114 deletions(-) create mode 100644 src/manim_voiceover/services/gemini.py diff --git a/docs/source/api.rst b/docs/source/api.rst index e8e6406..bda0f34 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -46,6 +46,10 @@ Speech services :members: :show-inheritance: +.. automodule:: manim_voiceover.services.gemini + :members: + :show-inheritance: + Defaults ~~~~~~~~ diff --git a/docs/source/services.rst b/docs/source/services.rst index 2f699f6..c970b0a 100644 --- a/docs/source/services.rst +++ b/docs/source/services.rst @@ -42,6 +42,11 @@ Manim Voiceover defines the :py:class:`~~base.SpeechService` class for adding ne - No - No - It's a free API subsidized by Google, so there is a likelihood it may stop working in the future. + * - :py:class:`~gemini.GeminiService` + - Very good, human-like + - No + - Yes + - Requires a Gemini API key or Google Cloud ADC, and Python 3.10 or newer. * - :py:class:`~openai.OpenAIService` - Very good, human-like - No @@ -118,6 +123,39 @@ Install Manim Voiceover with the ``gtts`` extra in order to use :py:class:`~gtts Refer to the `example usage `__ to get started. +:py:class:`~gemini.GeminiService` +********************************* + +`Gemini text-to-speech `__ provides controllable text-to-speech through the Google Gen AI SDK. It requires an internet connection and Python 3.10 or newer. + +Install Manim Voiceover with the ``gemini`` extra in order to use :py:class:`~gemini.GeminiService`: + +.. code:: sh + + pip install "manim-voiceover[gemini]" + +For Gemini Developer API authentication, create a file called ``.env`` +that contains your API key in the same directory where you call Manim. + +.. code:: sh + + GEMINI_API_KEY="..." # insert the API key here + +Gemini uses API-key authentication by default: + +.. code:: python + + self.set_speech_service(GeminiService(voice="Kore")) + +For Google Cloud Vertex AI authentication, use Application Default +Credentials and set ``auth_mode="adc"``: + +.. code:: python + + self.set_speech_service( + GeminiService(voice="Kore", auth_mode="adc", project="my-project-id") + ) + :py:class:`~openai.OpenAIService` ************************************* `OpenAI `__ provides a text-to-speech service. It is through an API, so it requires an internet connection to work. It also requires an API key to use. Register for one `here `__. diff --git a/examples/voiceover-demo.py b/examples/voiceover-demo.py index 4dae0c6..c5ee9cd 100644 --- a/examples/voiceover-demo.py +++ b/examples/voiceover-demo.py @@ -1,21 +1,48 @@ from manim import * -import pygments.styles as code_styles from manim_voiceover import VoiceoverScene -from manim_voiceover.services.azure import AzureService +from manim_voiceover.services.gemini import GeminiService -code_style = code_styles.get_style_by_name("one-dark") +code_style = "one-dark" + +SCENE_HEADER_LINES = (1, 2) +SET_SERVICE_LINE = 3 +GEMINI_SERVICE_LINE = 4 +GEMINI_VOICE_LINE = 5 +GEMINI_AUTH_LINE = 6 +SERVICE_CLOSE_LINES = (7, 8) +CIRCLE_SETUP_LINES = (9, 10) +VOICEOVER_CONTEXT_LINE = 11 +VOICEOVER_PLAY_LINE = 12 +SHIFT_CONTEXT_LINE = 14 +SHIFT_DURATION_LINE = 15 + + +def demo_code_block(code_string): + return Code( + code_string=code_string, + add_line_numbers=False, + formatter_style=code_style, + background="window", + language="python", + paragraph_config={"font": "Menlo"}, + ) + + +def code_lines(code_block, start, end=None): + if end is None: + return code_block.code_lines[start - 1] + return code_block.code_lines[start - 1 : end] + + +def code_line_range(code_block, line_range): + return code_lines(code_block, line_range[0], line_range[1]) class VoiceoverDemo(VoiceoverScene): def construct(self): - # Initialize speech synthesis using Azure's TTS API - self.set_speech_service( - AzureService( - voice="en-US-AriaNeural", - style="newscast-casual", # global_speed=1.15 - ) - ) + # Initialize speech synthesis using Gemini's TTS API + self.set_speech_service(GeminiService(voice="Kore", auth_mode="adc")) banner = ManimBanner().scale(0.5) with self.voiceover(text="Hey Manim Community!"): @@ -31,20 +58,15 @@ def construct(self): self.wait(tracker.get_remaining_duration(buff=-1)) self.play(FadeOut(banner)) - demo_code = Code( - code='''tracker = self.add_voiceover_text( + demo_code = demo_code_block( + '''tracker = self.add_voiceover_text( """AI generated voices have become realistic enough for use in most content. Using neural text-to-speech frees you from the painstaking process of recording and manually syncing audio to your video.""" ) -self.play(Write(demo_code), run_time=tracker.duration)''', - insert_line_no=False, - style=code_style, - background="window", - font="Consolas", - language="python", +self.play(Write(demo_code), run_time=tracker.duration)''' ).rescale_to_fit(12, 0) tracker = self.add_voiceover_text( @@ -80,14 +102,13 @@ def construct(self): with self.voiceover(text="I would go on, but you get the idea."): self.play(FadeOut(circle)) - demo_code2 = Code( - code="""class VoiceoverDemo(VoiceoverScene): + demo_code2 = demo_code_block( + """class VoiceoverDemo(VoiceoverScene): def construct(self): self.set_speech_service( - AzureService( - voice="en-US-AriaNeural", - style="newscast-casual", - global_speed=1.15 + GeminiService( + voice="Kore", + auth_mode="adc", ) ) circle = Circle() @@ -96,64 +117,51 @@ def construct(self): self.play(Create(circle)) with self.voiceover(text="Let's shift it to the left 2 units.") as tracker: - self.play(circle.animate.shift(2 * LEFT), run_time=tracker.duration)""", - insert_line_no=False, - style=code_style, - background="window", - font="Consolas", - language="python", + self.play(circle.animate.shift(2 * LEFT), run_time=tracker.duration)""" ).rescale_to_fit(12, 0) with self.voiceover(text="Let's see how the API works!"): - self.play(FadeIn(demo_code2.background_mobject)) + self.play(FadeIn(demo_code2.background)) - with self.voiceover( - text="First, we create a scene using the Voiceover Scene class from the plugin." - ): - self.play(FadeIn(demo_code2.code[:2])) + with self.voiceover(text="First, we create a scene using the Voiceover Scene class from the plugin."): + self.play(FadeIn(code_line_range(demo_code2, SCENE_HEADER_LINES))) - with self.voiceover( - text="Then, we initialize the voiceover by setting the appropriate speech synthesizer." - ): - self.play(FadeIn(demo_code2.code[2])) + with self.voiceover(text="Then, we initialize the voiceover by setting the appropriate speech synthesizer."): + self.play(FadeIn(code_lines(demo_code2, SET_SERVICE_LINE))) - with self.voiceover(text="In this example, we use Azure Text-to-speech."): - self.play(FadeIn(demo_code2.code[3])) + with self.voiceover(text="In this example, we use Gemini text-to-speech."): + self.play(FadeIn(code_lines(demo_code2, GEMINI_SERVICE_LINE))) - with self.voiceover( - text="We use the English speaking neural voice called Aria." - ): - self.play(FadeIn(demo_code2.code[4])) + with self.voiceover(text="We use the prebuilt Gemini voice called Kore."): + self.play(FadeIn(code_lines(demo_code2, GEMINI_VOICE_LINE))) - with self.voiceover(text='We use the style called "newscast casual".'): - self.play(FadeIn(demo_code2.code[5])) + with self.voiceover(text="We authenticate with Application Default Credentials."): + self.play(FadeIn(code_lines(demo_code2, GEMINI_AUTH_LINE))) with self.voiceover( - text="""Finally, we give an option to speed up the voiceover - playback fifteen percent, because the default is a bit too slow.""" + text="""Finally, Gemini returns audio that Manim Voiceover stores + in the local voiceover cache for reuse.""" ): - self.play(FadeIn(demo_code2.code[6:9])) + self.play(FadeIn(code_line_range(demo_code2, SERVICE_CLOSE_LINES))) - with self.voiceover( - text="""With the configuration out of the way, it is time to animate.""" - ): + with self.voiceover(text="""With the configuration out of the way, it is time to animate."""): pass with self.voiceover(text="""Let's initialize the circle object."""): - self.play(FadeIn(demo_code2.code[9:11])) + self.play(FadeIn(code_line_range(demo_code2, CIRCLE_SETUP_LINES))) with self.voiceover( text="""Then, we need to tell the scene to start narrating, by calling the function "self-dot-voiceover".""" ): - self.play(FadeIn(demo_code2.code[11])) + self.play(FadeIn(code_lines(demo_code2, VOICEOVER_CONTEXT_LINE))) with self.voiceover( text="""By wrapping our animation inside a "with-statement", we ensure that once it finishes playing, it will also wait for the voiceover playback to finish.""" ): - self.play(FadeIn(demo_code2.code[12])) + self.play(FadeIn(code_lines(demo_code2, VOICEOVER_PLAY_LINE))) with self.voiceover( text="""This is extremely convenient, and let's you chain @@ -164,79 +172,61 @@ def construct(self): with self.voiceover( text="""We just need to repeat the same pattern with self-dot-voiceover and with-statements. Here is something cool.""" ): - self.play(FadeIn(demo_code2.code[14])) + self.play(FadeIn(code_lines(demo_code2, SHIFT_CONTEXT_LINE))) with self.voiceover( text="""We can retrieve the duration of the generated voiceover programmatically, and then use it to define for how long an animation should play.""" ): - self.play(FadeIn(demo_code2.code[15])) + self.play(FadeIn(code_lines(demo_code2, SHIFT_DURATION_LINE))) - demo_code3 = Code( - code="""class VoiceoverDemo(VoiceoverScene): + demo_code3 = demo_code_block( + """class VoiceoverDemo(VoiceoverScene): def construct(self): self.set_speech_service( - AzureService( - voice="en-US-AriaNeural", - style="newscast-casual", - global_speed=1.15 + GeminiService( + voice="Kore", + auth_mode="adc", ) ) # self.set_speech_service( # StitcherService("my_voice_recording.mp3") # ) - """, - insert_line_no=False, - style=code_style, - background="window", - font="Consolas", - language="python", + """ ).scale(0.85) demo_code4 = ( - Code( - code="""class VoiceoverDemo(VoiceoverScene): + demo_code_block( + """class VoiceoverDemo(VoiceoverScene): def construct(self): # self.set_speech_service( - # AzureService( - # voice="en-US-AriaNeural", - # style="newscast-casual", - # global_speed=1.15 + # GeminiService( + # voice="Kore", + # auth_mode="adc", # ) # ) # self.set_speech_service( # StitcherService("my_voice_recording.mp3") # ) - """, - insert_line_no=False, - style=code_style, - background="window", - font="Consolas", - language="python", + """ ) .scale(0.85) .align_to(demo_code3, LEFT) ) demo_code5 = ( - Code( - code="""class VoiceoverDemo(VoiceoverScene): + demo_code_block( + """class VoiceoverDemo(VoiceoverScene): def construct(self): # self.set_speech_service( - # AzureService( - # voice="en-US-AriaNeural", - # style="newscast-casual", - # global_speed=1.15 + # GeminiService( + # voice="Kore", + # auth_mode="adc", # ) # ) self.set_speech_service( StitcherService("my_voice_recording.mp3") ) - """, - insert_line_no=False, - style=code_style, - background="window", - font="Consolas", - language="python", + """ ) .scale(0.85) .align_to(demo_code3, LEFT) @@ -258,31 +248,21 @@ def construct(self): self.wait() self.play(FadeOut(text1, text2, arrow)) - with self.voiceover( - text="To do that, you record an MP3 of the final text of your video." - ): + with self.voiceover(text="To do that, you record an MP3 of the final text of your video."): self.play(FadeIn(demo_code3)) with self.voiceover( text="""Manim-voiceover then splits your audio automatically and replaces the AI generated voice with your real recording.""" ): - self.play(FadeOut(demo_code3.code), FadeIn(demo_code4.code)) - self.play(FadeOut(demo_code4.code), FadeIn(demo_code5.code)) + self.play(FadeOut(demo_code3.code_lines), FadeIn(demo_code4.code_lines)) + self.play(FadeOut(demo_code4.code_lines), FadeIn(demo_code5.code_lines)) self.wait(2) - with self.voiceover( - text="""Manim-voiceover makes it much easier to do voiceovers for Manim projects.""" - ): - self.play(FadeOut(demo_code5.code, demo_code3.background_mobject)) + with self.voiceover(text="""Manim-voiceover makes it much easier to do voiceovers for Manim projects."""): + self.play(FadeOut(demo_code5.code_lines, demo_code3.background)) - with self.voiceover( - text="Visit the GitHub repo to start using it in your project." - ): - self.play( - FadeIn( - Tex(r"\texttt{https://github.com/ManimCommunity/manim-voiceover}") - ) - ) + with self.voiceover(text="Visit the GitHub repo to start using it in your project."): + self.play(FadeIn(Tex(r"\texttt{https://github.com/ManimCommunity/manim-voiceover}"))) self.wait(5) diff --git a/pyproject.toml b/pyproject.toml index 28c8b74..4113209 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ pyttsx3 = ["pyttsx3>=2.90,<3"] recorder = ["PyAudio>=0.2.12,<0.3", "pynput>=1.7.6,<2"] translate = ["deepl>=1.12.0,<2"] elevenlabs = ["elevenlabs>=0.2.27,<0.3"] +gemini = ["google-genai>=1.0,<3; python_version >= '3.10'"] transcribe = ["openai-whisper>=20230314", "stable-ts>=2.6.2,<3"] all = [ "azure-cognitiveservices-speech>=1.24.0,<2", @@ -65,6 +66,7 @@ all = [ "openai-whisper>=20230314", "stable-ts>=2.6.2,<3", "elevenlabs>=0.2.27,<0.3", + "google-genai>=1.0,<3; python_version >= '3.10'", ] [project.urls] @@ -106,6 +108,7 @@ dev = [ "matplotlib>=3.3.2,<4", "pre-commit>=2.11.1,<3", "gitpython>=3,<4", + "google-genai>=1.0,<3; python_version >= '3.10'", "pygithub>=1,<2", "isort>=5.8.0,<6", "pytest-xdist>=3.6,<4", @@ -173,6 +176,7 @@ module = [ "pydub", "pydub.*", "pyttsx3", + "google.*", "scipy.interpolate", "sox", ] diff --git a/src/manim_voiceover/modify_audio.py b/src/manim_voiceover/modify_audio.py index 58fe8ff..fb66f37 100644 --- a/src/manim_voiceover/modify_audio.py +++ b/src/manim_voiceover/modify_audio.py @@ -1,14 +1,28 @@ import os import uuid from pathlib import Path -from typing import Union +from typing import Optional, Protocol, Union import sox from mutagen.mp3 import MP3 +from mutagen.wave import WAVE PathLike = Union[str, Path] +class _AudioInfo(Protocol): + length: float + + +class _AudioFile(Protocol): + info: Optional[_AudioInfo] + + +def _read_wave(path: PathLike) -> _AudioFile: + # Mutagen WAVE is untyped but exposes the info.length shape used below. + return WAVE(path) # type: ignore[no-untyped-call, return-value] + + def adjust_speed(input_path: str, output_path: str, tempo: float) -> None: final_output_path = output_path if input_path == output_path: @@ -23,6 +37,14 @@ def adjust_speed(input_path: str, output_path: str, tempo: float) -> None: def get_duration(path: PathLike) -> float: + path_string = str(path) + if path_string.endswith(".wav"): + audio = _read_wave(path) + info = audio.info + if info is None: + raise ValueError(f"Could not read WAVE metadata from {path}") + return float(info.length) + audio = MP3(path) info = audio.info if info is None: diff --git a/src/manim_voiceover/services/gemini.py b/src/manim_voiceover/services/gemini.py new file mode 100644 index 0000000..401055f --- /dev/null +++ b/src/manim_voiceover/services/gemini.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import os +import sys +import wave +from pathlib import Path +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Protocol, Tuple, cast + +from dotenv import find_dotenv, load_dotenv +from manim import logger + +from manim_voiceover._typing import JsonValue, VoiceoverData +from manim_voiceover.helper import create_dotenv_file, prompt_ask_missing_extras, remove_bookmarks +from manim_voiceover.services.base import PathLike, SpeechService, initialize_speech_service, path_to_string + +try: + import google.auth + from google import genai + from google.genai import types +except ImportError: + logger.error('Missing packages. Run `pip install "manim-voiceover[gemini]"` to use GeminiService.') + + +if TYPE_CHECKING: + from google.auth.credentials import Credentials + + +load_dotenv(find_dotenv(usecwd=True)) + +GeminiAuthMode = Literal["api_key", "adc"] + +GEMINI_API_KEY_NAMES = ["GOOGLE_API_KEY", "GEMINI_API_KEY"] +GEMINI_AUTH_MODE_NAME = "GEMINI_AUTH_MODE" +GEMINI_PROJECT_NAMES = ["GOOGLE_CLOUD_PROJECT", "GEMINI_PROJECT"] +GEMINI_LOCATION_NAMES = ["GOOGLE_CLOUD_LOCATION", "GEMINI_LOCATION"] +DEFAULT_GEMINI_TTS_MODEL = "gemini-3.1-flash-tts-preview" +DEFAULT_GEMINI_VOICE = "Kore" +DEFAULT_GEMINI_LOCATION = "global" +GEMINI_SAMPLE_RATE = 24000 +GEMINI_SAMPLE_WIDTH = 2 +GEMINI_CHANNELS = 1 +ADC_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"] + + +class _GeminiModels(Protocol): + def generate_content(self, **kwargs: object) -> object: ... + + +class _GeminiClient(Protocol): + @property + def models(self) -> _GeminiModels: ... + + +def _as_gemini_client(client: object) -> _GeminiClient: + return cast(_GeminiClient, client) + + +def create_dotenv_gemini() -> None: + logger.info( + "Create a Gemini API key at https://aistudio.google.com/app/apikey and set it as GEMINI_API_KEY or GOOGLE_API_KEY." + ) + if not create_dotenv_file(GEMINI_API_KEY_NAMES): + raise ValueError( + "The environment variables GEMINI_API_KEY and GOOGLE_API_KEY are not set. " + "Please set one of them or create a .env file with the variables." + ) + logger.info("The .env file has been created. Please run Manim again.") + sys.exit() + + +def _get_gemini_api_key() -> str: + for name in GEMINI_API_KEY_NAMES: + value = os.getenv(name) + if value is not None: + return value + create_dotenv_gemini() + raise RuntimeError("Gemini API key setup did not exit.") + + +def _resolve_auth_mode(auth_mode: Optional[str]) -> GeminiAuthMode: + raw_auth_mode = auth_mode or os.getenv(GEMINI_AUTH_MODE_NAME) or "api_key" + if raw_auth_mode in ("api_key", "adc"): + return cast(GeminiAuthMode, raw_auth_mode) + raise ValueError('auth_mode must be "api_key" or "adc"') + + +def _first_env_value(names: List[str]) -> Optional[str]: + for name in names: + value = os.getenv(name) + if value: + return value + return None + + +def _get_adc_client_config(project: Optional[str], location: Optional[str]) -> Tuple[Credentials, str, str]: + credentials, default_project = google.auth.default(scopes=ADC_SCOPES) + resolved_project = project or _first_env_value(GEMINI_PROJECT_NAMES) or default_project + if resolved_project is None: + raise ValueError( + "Gemini ADC authentication requires a Google Cloud project. " + "Set GOOGLE_CLOUD_PROJECT, GEMINI_PROJECT, or pass project=..." + ) + resolved_location = location or _first_env_value(GEMINI_LOCATION_NAMES) or DEFAULT_GEMINI_LOCATION + return credentials, resolved_project, resolved_location + + +def _create_client( + auth_mode: Optional[GeminiAuthMode], + project: Optional[str], + location: Optional[str], +) -> _GeminiClient: + if _resolve_auth_mode(auth_mode) == "adc": + credentials, resolved_project, resolved_location = _get_adc_client_config(project, location) + return _as_gemini_client( + genai.Client( + vertexai=True, + credentials=credentials, + project=resolved_project, + location=resolved_location, + ) + ) + return _as_gemini_client(genai.Client(api_key=_get_gemini_api_key())) + + +def _required_object_attribute(value: object, name: str) -> object: + try: + return getattr(value, name) + except AttributeError as exc: + raise TypeError(f"Gemini response is missing {name}") from exc + + +def _required_non_empty_sequence(value: object, name: str) -> object: + if not isinstance(value, (list, tuple)): + raise TypeError(f"Gemini response {name} must be a sequence") + if len(value) == 0: + raise ValueError(f"Gemini response {name} must not be empty") + return value[0] + + +def _extract_pcm_audio(response: object) -> bytes: + candidates = _required_object_attribute(response, "candidates") + candidate = _required_non_empty_sequence(candidates, "candidates") + content = _required_object_attribute(candidate, "content") + parts = _required_object_attribute(content, "parts") + part = _required_non_empty_sequence(parts, "parts") + inline_data = _required_object_attribute(part, "inline_data") + data = _required_object_attribute(inline_data, "data") + if not isinstance(data, bytes): + raise TypeError("Gemini response inline audio data must be bytes") + return data + + +def _write_wave_file(path: Path, pcm_audio: bytes) -> None: + with wave.open(str(path), "wb") as wave_file: + wave_file.setnchannels(GEMINI_CHANNELS) + wave_file.setsampwidth(GEMINI_SAMPLE_WIDTH) + wave_file.setframerate(GEMINI_SAMPLE_RATE) + wave_file.writeframes(pcm_audio) + + +class GeminiService(SpeechService): + """ + Speech service class for Gemini text-to-speech. + + Gemini TTS uses the Google Gen AI SDK and returns raw PCM audio. This + service writes that audio as a mono 24 kHz WAV file. + """ + + def __init__( + self, + voice: str = DEFAULT_GEMINI_VOICE, + model: str = DEFAULT_GEMINI_TTS_MODEL, + transcription_model: Optional[str] = None, + auth_mode: Optional[GeminiAuthMode] = None, + project: Optional[str] = None, + location: Optional[str] = None, + **kwargs: object, + ) -> None: + prompt_ask_missing_extras("google.genai", "gemini", "GeminiService") + self.voice = voice + self.model = model + initialize_speech_service(self, kwargs, transcription_model=transcription_model) + self.client = _create_client(auth_mode, project, location) + + def generate_from_text( + self, + text: str, + cache_dir: Optional[PathLike] = None, + path: Optional[PathLike] = None, + **kwargs: object, + ) -> VoiceoverData: + """""" + if cache_dir is None: + cache_dir = self.cache_dir + + input_text = remove_bookmarks(text) + input_data = self._input_data(input_text) + + cached_result = self.get_cached_result(input_data, cache_dir) + if cached_result is not None: + return cached_result + + if path is None: + audio_path = self.get_audio_basename(input_data) + ".wav" + else: + audio_path = path_to_string(path) + + response = self.client.models.generate_content( + model=self.model, + contents=input_text, + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name=self.voice, + ) + ) + ), + ), + ) + _write_wave_file(Path(cache_dir) / audio_path, _extract_pcm_audio(response)) + + json_dict: VoiceoverData = { + "input_text": text, + "input_data": input_data, + "original_audio": audio_path, + } + + return json_dict + + def _input_data(self, input_text: str) -> Dict[str, JsonValue]: + return { + "input_text": input_text, + "service": "gemini", + "config": { + "voice": self.voice, + "model": self.model, + }, + } diff --git a/tests/test_edge_coverage.py b/tests/test_edge_coverage.py index df889f1..c5ee24f 100644 --- a/tests/test_edge_coverage.py +++ b/tests/test_edge_coverage.py @@ -41,6 +41,14 @@ def build(self, input_filepath, output_filepath): assert get_duration(input_path) == 3.0 assert seen_paths == [input_path] + monkeypatch.setattr( + "manim_voiceover.modify_audio.WAVE", + lambda path: seen_paths.append(path) or SimpleNamespace(info=SimpleNamespace(length=4.0)), + ) + wav_path = tmp_path / "audio.wav" + assert get_duration(wav_path) == 4.0 + assert seen_paths == [input_path, wav_path] + monkeypatch.setattr( "manim_voiceover.modify_audio.MP3", lambda path: seen_paths.append(path) or SimpleNamespace(info=None), @@ -48,7 +56,16 @@ def build(self, input_filepath, output_filepath): with pytest.raises(ValueError) as exc_info: get_duration(input_path) assert str(exc_info.value) == f"Could not read MP3 metadata from {input_path}" - assert seen_paths == [input_path, input_path] + assert seen_paths == [input_path, wav_path, input_path] + + monkeypatch.setattr( + "manim_voiceover.modify_audio.WAVE", + lambda path: seen_paths.append(path) or SimpleNamespace(info=None), + ) + with pytest.raises(ValueError) as wav_exc_info: + get_duration(wav_path) + assert str(wav_exc_info.value) == f"Could not read WAVE metadata from {wav_path}" + assert seen_paths == [input_path, wav_path, input_path, wav_path] def test_azure_helpers_and_errors(monkeypatch): @@ -340,6 +357,119 @@ def stream_to_file(self, path): service.generate_from_text("hello") +def test_gemini_cache_dotenv_key_and_response_errors(monkeypatch, tmp_path): + import manim_voiceover.services.gemini as gemini + + class CachedService(gemini.GeminiService): + def get_cached_result(self, input_data, cache_dir): + assert input_data == { + "input_text": "cached", + "service": "gemini", + "config": { + "voice": "Kore", + "model": "gemini-tts", + }, + } + assert cache_dir == tmp_path + return {"input_text": "cached", "original_audio": "cached.wav"} + + cached = CachedService.__new__(CachedService) + cached.cache_dir = tmp_path + cached.voice = "Kore" + cached.model = "gemini-tts" + cached.get_audio_basename = lambda input_data: "audio" + cached.client = SimpleNamespace( + models=SimpleNamespace(generate_content=lambda **kwargs: pytest.fail("Cached Gemini result should not call SDK.")) + ) + assert cached.generate_from_text("cached") == {"input_text": "cached", "original_audio": "cached.wav"} + + monkeypatch.setenv("GOOGLE_API_KEY", "google-key") + monkeypatch.setenv("GEMINI_API_KEY", "gemini-key") + assert gemini._get_gemini_api_key() == "google-key" + + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + assert gemini._get_gemini_api_key() == "gemini-key" + + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + real_create_dotenv_gemini = gemini.create_dotenv_gemini + monkeypatch.setattr("manim_voiceover.services.gemini.create_dotenv_gemini", lambda: (_ for _ in ()).throw(SystemExit())) + with pytest.raises(SystemExit): + gemini._get_gemini_api_key() + monkeypatch.setattr("manim_voiceover.services.gemini.create_dotenv_gemini", real_create_dotenv_gemini) + + monkeypatch.setattr("manim_voiceover.services.gemini.create_dotenv_gemini", lambda: None) + with pytest.raises(RuntimeError) as runtime_exc_info: + gemini._get_gemini_api_key() + assert str(runtime_exc_info.value) == "Gemini API key setup did not exit." + monkeypatch.setattr("manim_voiceover.services.gemini.create_dotenv_gemini", real_create_dotenv_gemini) + + assert gemini._resolve_auth_mode(None) == "api_key" + monkeypatch.setenv("GEMINI_AUTH_MODE", "adc") + assert gemini._resolve_auth_mode(None) == "adc" + assert gemini._resolve_auth_mode("api_key") == "api_key" + monkeypatch.setenv("GEMINI_AUTH_MODE", "bad") + with pytest.raises(ValueError) as auth_mode_exc_info: + gemini._resolve_auth_mode(None) + assert str(auth_mode_exc_info.value) == 'auth_mode must be "api_key" or "adc"' + monkeypatch.delenv("GEMINI_AUTH_MODE", raising=False) + + logs = [] + dotenv_calls = [] + monkeypatch.setattr("manim_voiceover.services.gemini.logger.info", lambda message: logs.append(message)) + monkeypatch.setattr( + "manim_voiceover.services.gemini.create_dotenv_file", + lambda names: dotenv_calls.append(names) or False, + ) + with pytest.raises(ValueError) as exc_info: + gemini.create_dotenv_gemini() + assert str(exc_info.value) == ( + "The environment variables GEMINI_API_KEY and GOOGLE_API_KEY are not set. " + "Please set one of them or create a .env file with the variables." + ) + assert logs[0].startswith("Create a Gemini API key") + assert dotenv_calls == [gemini.GEMINI_API_KEY_NAMES] + + logs.clear() + dotenv_calls.clear() + monkeypatch.setattr( + "manim_voiceover.services.gemini.create_dotenv_file", + lambda names: dotenv_calls.append(names) or True, + ) + with pytest.raises(SystemExit) as exit_info: + gemini.create_dotenv_gemini() + assert exit_info.value.code is None + assert logs[-1] == "The .env file has been created. Please run Manim again." + assert dotenv_calls == [gemini.GEMINI_API_KEY_NAMES] + + with pytest.raises(TypeError) as missing_exc_info: + gemini._extract_pcm_audio(SimpleNamespace()) + assert str(missing_exc_info.value) == "Gemini response is missing candidates" + + with pytest.raises(TypeError) as sequence_exc_info: + gemini._extract_pcm_audio(SimpleNamespace(candidates=object())) + assert str(sequence_exc_info.value) == "Gemini response candidates must be a sequence" + + with pytest.raises(ValueError) as empty_exc_info: + gemini._extract_pcm_audio(SimpleNamespace(candidates=[])) + assert str(empty_exc_info.value) == "Gemini response candidates must not be empty" + + with pytest.raises(ValueError) as empty_parts_exc_info: + gemini._extract_pcm_audio(SimpleNamespace(candidates=[SimpleNamespace(content=SimpleNamespace(parts=[]))])) + assert str(empty_parts_exc_info.value) == "Gemini response parts must not be empty" + + with pytest.raises(TypeError) as bytes_exc_info: + gemini._extract_pcm_audio( + SimpleNamespace( + candidates=[ + SimpleNamespace( + content=SimpleNamespace(parts=[SimpleNamespace(inline_data=SimpleNamespace(data="not-bytes"))]) + ) + ] + ) + ) + assert str(bytes_exc_info.value) == "Gemini response inline audio data must be bytes" + + def test_elevenlabs_helpers(monkeypatch, tmp_path): import manim_voiceover.services.elevenlabs as eleven diff --git a/tests/test_service_adapters.py b/tests/test_service_adapters.py index 0d48560..6928104 100644 --- a/tests/test_service_adapters.py +++ b/tests/test_service_adapters.py @@ -135,6 +135,131 @@ def test_elevenlabs_service_generate(tmp_path, monkeypatch): assert result["input_data"]["service"] == "elevenlabs" +def test_gemini_service_generate(tmp_path, monkeypatch): + import manim_voiceover.services.gemini as gemini + from manim_voiceover.services.gemini import GeminiService + + clients = [] + extras_calls = [] + init_calls = [] + + class FakeModels: + def generate_content(self, **kwargs): + calls.append(kwargs) + return SimpleNamespace( + candidates=[ + SimpleNamespace( + content=SimpleNamespace(parts=[SimpleNamespace(inline_data=SimpleNamespace(data=b"\x00\x00\x01\x00"))]) + ) + ] + ) + + class FakeClient: + def __init__(self, **kwargs): + clients.append(kwargs) + self.models = FakeModels() + + class FakeTypes: + class GenerateContentConfig: + def __init__(self, **kwargs): + self.kwargs = kwargs + + class SpeechConfig: + def __init__(self, **kwargs): + self.kwargs = kwargs + + class VoiceConfig: + def __init__(self, **kwargs): + self.kwargs = kwargs + + class PrebuiltVoiceConfig: + def __init__(self, **kwargs): + self.kwargs = kwargs + + calls = [] + monkeypatch.setenv("GEMINI_API_KEY", "key") + monkeypatch.setattr("manim_voiceover.services.gemini.genai", SimpleNamespace(Client=FakeClient)) + monkeypatch.setattr("manim_voiceover.services.gemini.types", FakeTypes) + monkeypatch.setattr( + "manim_voiceover.services.gemini.prompt_ask_missing_extras", + lambda *args: extras_calls.append(args), + ) + + def fake_initialize_speech_service(service, kwargs, *, transcription_model=None): + init_calls.append((kwargs, transcription_model)) + service.cache_dir = kwargs["cache_dir"] + service.transcription_model = transcription_model + + monkeypatch.setattr("manim_voiceover.services.gemini.initialize_speech_service", fake_initialize_speech_service) + + service = GeminiService(cache_dir=tmp_path, voice="Kore", model="gemini-tts", transcription_model="base") + result = service.generate_from_text("hello ", path="gemini.wav") + + assert extras_calls == [("google.genai", "gemini", "GeminiService")] + assert init_calls == [({"cache_dir": tmp_path}, "base")] + assert clients == [{"api_key": "key"}] + assert result["input_text"] == "hello " + assert result["original_audio"] == "gemini.wav" + assert result["input_data"] == { + "input_text": "hello ", + "service": "gemini", + "config": { + "voice": "Kore", + "model": "gemini-tts", + }, + } + assert calls[0]["model"] == "gemini-tts" + assert calls[0]["contents"] == "hello " + config = calls[0]["config"] + assert config.kwargs["response_modalities"] == ["AUDIO"] + speech_config = config.kwargs["speech_config"] + voice_config = speech_config.kwargs["voice_config"] + prebuilt_voice_config = voice_config.kwargs["prebuilt_voice_config"] + assert prebuilt_voice_config.kwargs["voice_name"] == "Kore" + + generated_path = tmp_path / "gemini.wav" + assert generated_path.read_bytes().startswith(b"RIFF") + + basename_inputs = [] + monkeypatch.setattr( + service, + "get_audio_basename", + lambda input_data: basename_inputs.append(input_data) or "gemini-generated", + ) + generated = service.generate_from_text("basename ") + assert basename_inputs == [generated["input_data"]] + assert generated["input_text"] == "basename " + assert generated["original_audio"] == "gemini-generated.wav" + assert (tmp_path / "gemini-generated.wav").read_bytes().startswith(b"RIFF") + + assert ( + gemini._extract_pcm_audio( + SimpleNamespace( + candidates=[ + SimpleNamespace(content=SimpleNamespace(parts=[SimpleNamespace(inline_data=SimpleNamespace(data=b"pcm"))])) + ] + ) + ) + == b"pcm" + ) + + clients.clear() + adc_credentials = object() + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + monkeypatch.setattr( + "manim_voiceover.services.gemini.google.auth.default", lambda scopes: (adc_credentials, "default-project") + ) + GeminiService(cache_dir=tmp_path, auth_mode="adc", project="cloud-project", location="us-central1") + assert clients == [ + { + "vertexai": True, + "credentials": adc_credentials, + "project": "cloud-project", + "location": "us-central1", + } + ] + + def test_azure_service_helpers_and_generate(tmp_path, monkeypatch): from manim_voiceover.services.azure import AzureService, serialize_word_boundary diff --git a/uv.lock b/uv.lock index f79528c..d0f7423 100644 --- a/uv.lock +++ b/uv.lock @@ -2446,6 +2446,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/94/d4c9cdf99ac89c673454c3dbf385cb48385d05df8394784902e5f0e00801/glcontext-3.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c3648e13478d77128a74dd25baa98faf9ddb9cbcba5af39775ef3a496f71fd10", size = 12995, upload-time = "2024-08-10T20:01:19.046Z" }, ] +[[package]] +name = "google-auth" +version = "2.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography", version = "48.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyasn1-modules", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/f6/494e18317546d7def90c957b71d68b025d24f0e22e486c2606bc57765c48/google_auth-2.54.0.tar.gz", hash = "sha256:130f6fd5e3f497fdad897a23ed9489973437edf561238c4b92a4d02c435f8af9", size = 343161, upload-time = "2026-06-12T18:03:17.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/c5/d53bddd2c0949833fcb4ea06f9d5dd1c40575a1a4214cd1021eff57ba301/google_auth-2.54.0-py3-none-any.whl", hash = "sha256:784e9837f92244141250470d47c893df50cbab485ce491aca5e9deb558ad2b48", size = 249878, upload-time = "2026-06-12T18:02:57.58Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[[package]] +name = "google-genai" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "distro", marker = "python_full_version >= '3.10'" }, + { name = "google-auth", extra = ["requests"], marker = "python_full_version >= '3.10'" }, + { name = "httpx", marker = "python_full_version >= '3.10'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "sniffio", marker = "python_full_version >= '3.10'" }, + { name = "tenacity", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "websockets", version = "16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/52/0244e310812f3063d09d60b30ae29ab7df9343bd005744cd5eeaa6ba39b4/google_genai-2.8.0.tar.gz", hash = "sha256:37a9b3cb127d763e7f4ca47452ae3562c87728773bd1b149f7b559c239da2bc1", size = 564955, upload-time = "2026-06-03T22:55:38.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/de/747ad1aa49e902da9a4699081c282a1ed8ceed3b4d295fd99a6d286e09e4/google_genai-2.8.0-py3-none-any.whl", hash = "sha256:4da0a223a100f4b37f609a68b835e3326ab0fa313314dc0fd9d34e76ee293844", size = 832497, upload-time = "2026-06-03T22:55:36.598Z" }, +] + [[package]] name = "gtts" version = "2.5.4" @@ -3984,6 +4023,7 @@ all = [ { name = "deepl", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "deepl", version = "1.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "elevenlabs" }, + { name = "google-genai", marker = "python_full_version >= '3.10'" }, { name = "gtts" }, { name = "openai" }, { name = "openai-whisper" }, @@ -3998,6 +4038,9 @@ azure = [ elevenlabs = [ { name = "elevenlabs" }, ] +gemini = [ + { name = "google-genai", marker = "python_full_version >= '3.10'" }, +] gtts = [ { name = "gtts" }, ] @@ -4028,6 +4071,7 @@ dev = [ { name = "elevenlabs" }, { name = "furo" }, { name = "gitpython" }, + { name = "google-genai", marker = "python_full_version >= '3.10'" }, { name = "gtts" }, { name = "ipdb" }, { name = "isort" }, @@ -4081,6 +4125,8 @@ requires-dist = [ { name = "deepl", marker = "extra == 'translate'", specifier = ">=1.12.0,<2" }, { name = "elevenlabs", marker = "extra == 'all'", specifier = ">=0.2.27,<0.3" }, { name = "elevenlabs", marker = "extra == 'elevenlabs'", specifier = ">=0.2.27,<0.3" }, + { name = "google-genai", marker = "python_full_version >= '3.10' and extra == 'all'", specifier = ">=1.0,<3" }, + { name = "google-genai", marker = "python_full_version >= '3.10' and extra == 'gemini'", specifier = ">=1.0,<3" }, { name = "gtts", marker = "extra == 'all'", specifier = ">=2.2.4,<3" }, { name = "gtts", marker = "extra == 'gtts'", specifier = ">=2.2.4,<3" }, { name = "manim" }, @@ -4105,7 +4151,7 @@ requires-dist = [ { name = "stable-ts", marker = "extra == 'all'", specifier = ">=2.6.2,<3" }, { name = "stable-ts", marker = "extra == 'transcribe'", specifier = ">=2.6.2,<3" }, ] -provides-extras = ["azure", "gtts", "openai", "pyttsx3", "recorder", "translate", "elevenlabs", "transcribe", "all"] +provides-extras = ["azure", "gtts", "openai", "pyttsx3", "recorder", "translate", "elevenlabs", "gemini", "transcribe", "all"] [package.metadata.requires-dev] dev = [ @@ -4114,6 +4160,7 @@ dev = [ { name = "elevenlabs", specifier = ">=0.2.27,<0.3" }, { name = "furo", specifier = ">=2022.9.29,<2023.0.0" }, { name = "gitpython", specifier = ">=3,<4" }, + { name = "google-genai", marker = "python_full_version >= '3.10'", specifier = ">=1.0,<3" }, { name = "gtts", specifier = ">=2.2.4,<3" }, { name = "ipdb", specifier = ">=0.13,<0.14" }, { name = "isort", specifier = ">=5.8.0,<6" }, @@ -6775,6 +6822,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pyaudio" version = "0.2.14" @@ -20067,6 +20135,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "text-unidecode" version = "1.3" From 90412f013f0e9a474f899fa81c2a8d7183e2fbb8 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:32:16 +0800 Subject: [PATCH 2/2] test: cover Gemini ADC configuration --- src/manim_voiceover/services/gemini.py | 28 +++++------ tests/test_edge_coverage.py | 67 +++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/src/manim_voiceover/services/gemini.py b/src/manim_voiceover/services/gemini.py index 401055f..e7e4be3 100644 --- a/src/manim_voiceover/services/gemini.py +++ b/src/manim_voiceover/services/gemini.py @@ -4,7 +4,7 @@ import sys import wave from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Protocol, Tuple, cast +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Protocol, Tuple from dotenv import find_dotenv, load_dotenv from manim import logger @@ -43,7 +43,7 @@ class _GeminiModels(Protocol): - def generate_content(self, **kwargs: object) -> object: ... + def generate_content(self, *, model: str, contents: str, config: types.GenerateContentConfig) -> object: ... class _GeminiClient(Protocol): @@ -51,10 +51,6 @@ class _GeminiClient(Protocol): def models(self) -> _GeminiModels: ... -def _as_gemini_client(client: object) -> _GeminiClient: - return cast(_GeminiClient, client) - - def create_dotenv_gemini() -> None: logger.info( "Create a Gemini API key at https://aistudio.google.com/app/apikey and set it as GEMINI_API_KEY or GOOGLE_API_KEY." @@ -79,8 +75,10 @@ def _get_gemini_api_key() -> str: def _resolve_auth_mode(auth_mode: Optional[str]) -> GeminiAuthMode: raw_auth_mode = auth_mode or os.getenv(GEMINI_AUTH_MODE_NAME) or "api_key" - if raw_auth_mode in ("api_key", "adc"): - return cast(GeminiAuthMode, raw_auth_mode) + if raw_auth_mode == "api_key": + return "api_key" + if raw_auth_mode == "adc": + return "adc" raise ValueError('auth_mode must be "api_key" or "adc"') @@ -111,15 +109,13 @@ def _create_client( ) -> _GeminiClient: if _resolve_auth_mode(auth_mode) == "adc": credentials, resolved_project, resolved_location = _get_adc_client_config(project, location) - return _as_gemini_client( - genai.Client( - vertexai=True, - credentials=credentials, - project=resolved_project, - location=resolved_location, - ) + return genai.Client( + vertexai=True, + credentials=credentials, + project=resolved_project, + location=resolved_location, ) - return _as_gemini_client(genai.Client(api_key=_get_gemini_api_key())) + return genai.Client(api_key=_get_gemini_api_key()) def _required_object_attribute(value: object, name: str) -> object: diff --git a/tests/test_edge_coverage.py b/tests/test_edge_coverage.py index c5ee24f..cb19753 100644 --- a/tests/test_edge_coverage.py +++ b/tests/test_edge_coverage.py @@ -411,7 +411,6 @@ def get_cached_result(self, input_data, cache_dir): with pytest.raises(ValueError) as auth_mode_exc_info: gemini._resolve_auth_mode(None) assert str(auth_mode_exc_info.value) == 'auth_mode must be "api_key" or "adc"' - monkeypatch.delenv("GEMINI_AUTH_MODE", raising=False) logs = [] dotenv_calls = [] @@ -470,6 +469,72 @@ def get_cached_result(self, input_data, cache_dir): assert str(bytes_exc_info.value) == "Gemini response inline audio data must be bytes" +def test_gemini_first_env_value_prefers_first_non_empty_env(monkeypatch): + import manim_voiceover.services.gemini as gemini + + for name in ["GEMINI_TEST_MISSING", "GEMINI_TEST_EMPTY", "GEMINI_TEST_VALUE", "GEMINI_TEST_FIRST"]: + monkeypatch.delenv(name, raising=False) + + assert gemini._first_env_value(["GEMINI_TEST_MISSING", "GEMINI_TEST_EMPTY"]) is None + + monkeypatch.setenv("GEMINI_TEST_EMPTY", "") + monkeypatch.setenv("GEMINI_TEST_VALUE", "env-value") + assert gemini._first_env_value(["GEMINI_TEST_EMPTY", "GEMINI_TEST_VALUE"]) == "env-value" + + monkeypatch.setenv("GEMINI_TEST_FIRST", "first-value") + assert gemini._first_env_value(["GEMINI_TEST_FIRST", "GEMINI_TEST_VALUE"]) == "first-value" + + +def test_gemini_adc_client_config_resolution(monkeypatch): + import manim_voiceover.services.gemini as gemini + + for name in gemini.GEMINI_PROJECT_NAMES + gemini.GEMINI_LOCATION_NAMES: + monkeypatch.delenv(name, raising=False) + + default_calls = [] + adc_credentials = object() + + def fake_default(*, scopes): + default_calls.append(scopes) + return adc_credentials, "default-project" + + monkeypatch.setattr("manim_voiceover.services.gemini.google.auth.default", fake_default) + monkeypatch.setenv("GEMINI_PROJECT", "env-project") + monkeypatch.setenv("GEMINI_LOCATION", "asia-southeast1") + credentials, project, location = gemini._get_adc_client_config(None, None) + assert credentials is adc_credentials + assert project == "env-project" + assert location == "asia-southeast1" + assert default_calls == [gemini.ADC_SCOPES] + + monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "google-project") + monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1") + _, project, location = gemini._get_adc_client_config(None, None) + assert project == "google-project" + assert location == "us-central1" + + _, project, location = gemini._get_adc_client_config("explicit-project", "europe-west1") + assert project == "explicit-project" + assert location == "europe-west1" + + for name in gemini.GEMINI_PROJECT_NAMES + gemini.GEMINI_LOCATION_NAMES: + monkeypatch.delenv(name, raising=False) + _, project, location = gemini._get_adc_client_config(None, None) + assert project == "default-project" + assert location == gemini.DEFAULT_GEMINI_LOCATION + + monkeypatch.setattr( + "manim_voiceover.services.gemini.google.auth.default", + lambda *, scopes: (adc_credentials, None), + ) + with pytest.raises(ValueError) as adc_project_exc_info: + gemini._get_adc_client_config(None, None) + assert str(adc_project_exc_info.value) == ( + "Gemini ADC authentication requires a Google Cloud project. " + "Set GOOGLE_CLOUD_PROJECT, GEMINI_PROJECT, or pass project=..." + ) + + def test_elevenlabs_helpers(monkeypatch, tmp_path): import manim_voiceover.services.elevenlabs as eleven