diff --git a/.changeset/tricky-humans-poke.md b/.changeset/tricky-humans-poke.md new file mode 100644 index 0000000000..8fd1a03805 --- /dev/null +++ b/.changeset/tricky-humans-poke.md @@ -0,0 +1,5 @@ +--- +"gradio": minor +--- + +feat:Adds ability to watermark videos via a `watermark` parameter in Video component diff --git a/demo/video_watermark/files/a.mp4 b/demo/video_watermark/files/a.mp4 new file mode 100644 index 0000000000..95a61f6b4a Binary files /dev/null and b/demo/video_watermark/files/a.mp4 differ diff --git a/demo/video_watermark/files/b.mp4 b/demo/video_watermark/files/b.mp4 new file mode 100644 index 0000000000..7b2d7c723e Binary files /dev/null and b/demo/video_watermark/files/b.mp4 differ diff --git a/demo/video_watermark/files/w1.jpg b/demo/video_watermark/files/w1.jpg new file mode 100644 index 0000000000..c510ff30e0 Binary files /dev/null and b/demo/video_watermark/files/w1.jpg differ diff --git a/demo/video_watermark/files/w2.png b/demo/video_watermark/files/w2.png new file mode 100644 index 0000000000..855b404179 Binary files /dev/null and b/demo/video_watermark/files/w2.png differ diff --git a/demo/video_watermark/run.ipynb b/demo/video_watermark/run.ipynb new file mode 100644 index 0000000000..b66b9165b7 --- /dev/null +++ b/demo/video_watermark/run.ipynb @@ -0,0 +1 @@ +{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: video_watermark"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["# Downloading files from the demo repo\n", "import os\n", "os.mkdir('files')\n", "!wget -q -O files/a.mp4 https://github.com/gradio-app/gradio/raw/main/demo/video_watermark/files/a.mp4\n", "!wget -q -O files/b.mp4 https://github.com/gradio-app/gradio/raw/main/demo/video_watermark/files/b.mp4\n", "!wget -q -O files/w1.jpg https://github.com/gradio-app/gradio/raw/main/demo/video_watermark/files/w1.jpg\n", "!wget -q -O files/w2.png https://github.com/gradio-app/gradio/raw/main/demo/video_watermark/files/w2.png"]}, {"cell_type": "code", "execution_count": null, "id": "44380577570523278879349135829904343037", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "import os\n", "\n", "a = os.path.join(os.path.abspath(''), \"files/a.mp4\")\n", "b = os.path.join(os.path.abspath(''), \"files/b.mp4\")\n", "w1 = os.path.join(os.path.abspath(''), \"files/w1.jpg\")\n", "w2 = os.path.join(os.path.abspath(''), \"files/w2.png\")\n", "\n", "def generate_video(original_video, watermark):\n", " return gr.Video(original_video, watermark=watermark)\n", "\n", "\n", "demo = gr.Interface(generate_video, [gr.Video(), gr.File()], gr.Video(),\n", " examples=[[a, w1], [b, w2]])\n", "\n", "if __name__ == \"__main__\":\n", " demo.launch()\n"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5} \ No newline at end of file diff --git a/demo/video_watermark/run.py b/demo/video_watermark/run.py new file mode 100644 index 0000000000..1e89f380da --- /dev/null +++ b/demo/video_watermark/run.py @@ -0,0 +1,17 @@ +import gradio as gr +import os + +a = os.path.join(os.path.dirname(__file__), "files/a.mp4") +b = os.path.join(os.path.dirname(__file__), "files/b.mp4") +w1 = os.path.join(os.path.dirname(__file__), "files/w1.jpg") +w2 = os.path.join(os.path.dirname(__file__), "files/w2.png") + +def generate_video(original_video, watermark): + return gr.Video(original_video, watermark=watermark) + + +demo = gr.Interface(generate_video, [gr.Video(), gr.File()], gr.Video(), + examples=[[a, w1], [b, w2]]) + +if __name__ == "__main__": + demo.launch() diff --git a/gradio/components/video.py b/gradio/components/video.py index de09f00e74..7e03a7440c 100644 --- a/gradio/components/video.py +++ b/gradio/components/video.py @@ -91,12 +91,13 @@ class Video(Component): min_length: int | None = None, max_length: int | None = None, loop: bool = False, + watermark: str | Path | None = None, ): """ Parameters: value: A path or URL for the default value that Video component is going to take. Can also be a tuple consisting of (video filepath, subtitle filepath). If a subtitle file is provided, it should be of type .srt or .vtt. Or can be callable, in which case the function will be called whenever the app loads to set the initial value of the component. format: Format of video format to be returned by component, such as 'avi' or 'mp4'. Use 'mp4' to ensure browser playability. If set to None, video will keep uploaded format. - sources: A list of sources permitted for video. "upload" creates a box where user can drop an video file, "webcam" allows user to record a video from their webcam. If None, defaults to ["upload, "webcam"]. + sources: A list of sources permitted for video. "upload" creates a box where user can drop a video file, "webcam" allows user to record a video from their webcam. If None, defaults to ["upload, "webcam"]. height: The height of the displayed video, specified in pixels if a number is passed, or in CSS units if a string is passed. width: The width of the displayed video, specified in pixels if a number is passed, or in CSS units if a string is passed. label: The label for this component. Appears above the component and is also used as the header if there are a table of examples for this component. If None and used in a `gr.Interface`, the label will be the name of the parameter this component is assigned to. @@ -120,6 +121,7 @@ class Video(Component): min_length: The minimum length of video (in seconds) that the user can pass into the prediction function. If None, there is no minimum length. max_length: The maximum length of video (in seconds) that the user can pass into the prediction function. If None, there is no maximum length. loop: If True, the video will loop when it reaches the end and continue playing from the beginning. + watermark: An image file to be included as a watermark on the video. The image is not scaled and is displayed on the bottom right of the video. Valid formats for the image are: jpeg, png. """ valid_sources: list[Literal["upload", "webcam"]] = ["upload", "webcam"] if sources is None: @@ -154,6 +156,7 @@ class Video(Component): self.show_download_button = show_download_button self.min_length = min_length self.max_length = max_length + self.watermark = watermark super().__init__( label=label, every=every, @@ -199,7 +202,20 @@ class Video(Component): raise gr.Error( f"Video is too long, and must be at most {self.max_length} seconds" ) - + # TODO: Check other image extensions to see if they work. + valid_watermark_extensions = [".png", ".jpg", ".jpeg"] + if self.watermark is not None: + if not isinstance(self.watermark, (str, Path)): + raise ValueError( + f"Provided watermark file not an expected file type. " + f"Received: {self.watermark}" + ) + if Path(self.watermark).suffix not in valid_watermark_extensions: + raise ValueError( + f"Watermark file does not have a supported extension. " + f"Expected one of {','.join(valid_watermark_extensions)}. " + f"Received: {Path(self.watermark).suffix}." + ) if needs_formatting or flip: format = f".{self.format if needs_formatting else uploaded_format}" output_options = ["-vf", "hflip", "-c:a", "copy"] if flip else [] @@ -279,7 +295,8 @@ class Video(Component): def _format_video(self, video: str | Path | None) -> FileData | None: """ - Processes a video to ensure that it is in the correct format. + Processes a video to ensure that it is in the correct format + and adds a watermark if requested. """ if video is None: return None @@ -292,11 +309,13 @@ class Video(Component): is_url = client_utils.is_http_url_like(video) - # For cases where the video is a URL and does not need to be converted to another format, we can just return the URL - if is_url and not (conversion_needed): + # For cases where the video is a URL and does not need to be converted + # to another format and have a watermark added, we can just return the URL + if not self.watermark and (is_url and not conversion_needed): return FileData(path=video) # For cases where the video needs to be converted to another format + # or have a watermark added. if is_url: video = processing_utils.save_url_to_cache( video, cache_dir=self.GRADIO_CACHE @@ -306,21 +325,38 @@ class Video(Component): and not processing_utils.video_is_playable(video) ): warnings.warn( - "Video does not have browser-compatible container or codec. Converting to mp4" + "Video does not have browser-compatible container or codec. Converting to mp4." ) video = processing_utils.convert_video_to_playable_mp4(video) # Recalculate the format in case convert_video_to_playable_mp4 already made it the selected format returned_format = utils.get_extension_from_file_path_or_url(video).lower() - if self.format is not None and returned_format != self.format: + if ( + self.format is not None and returned_format != self.format + ) or self.watermark: if wasm_utils.IS_WASM: raise wasm_utils.WasmUnsupportedError( - "Returning a video in a different format is not supported in the Wasm mode." + "Modifying a video is not supported in the Wasm mode." + ) + global_option_list = ["-y"] + inputs_dict = {video: None} + output_file_name = video[0 : video.rindex(".") + 1] + if self.format is not None: + output_file_name += self.format + else: + output_file_name += returned_format + if self.watermark: + inputs_dict[str(self.watermark)] = None + watermark_cmd = "overlay=W-w-5:H-h-5" + global_option_list += ["-filter_complex", watermark_cmd] + output_file_name = ( + Path(output_file_name).stem + + "_watermarked" + + Path(output_file_name).suffix ) - output_file_name = video[0 : video.rindex(".") + 1] + self.format ff = FFmpeg( # type: ignore - inputs={video: None}, + inputs=inputs_dict, outputs={output_file_name: None}, - global_options="-y", + global_options=global_option_list, ) ff.run() video = output_file_name diff --git a/gradio/templates.py b/gradio/templates.py index ced24e19b7..ba9ed56f0f 100644 --- a/gradio/templates.py +++ b/gradio/templates.py @@ -371,6 +371,7 @@ class PlayableVideo(components.Video): min_length: int | None = None, max_length: int | None = None, loop: bool = False, + watermark: str | Path | None = None, ): sources = ["upload"] super().__init__( @@ -400,6 +401,7 @@ class PlayableVideo(components.Video): min_length=min_length, max_length=max_length, loop=loop, + watermark=watermark, ) diff --git a/test/components/test_video.py b/test/components/test_video.py index b50afabc98..89053e4cc3 100644 --- a/test/components/test_video.py +++ b/test/components/test_video.py @@ -68,6 +68,7 @@ class TestVideo: "_selectable": False, "key": None, "loop": False, + "watermark": None, } assert video_input.preprocess(None) is None video_input = gr.Video(format="avi")