elismasilva commited on
Commit
e545160
·
verified ·
1 Parent(s): ea19e8c

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -35,3 +35,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  src/examples/SampleVideo[[:space:]]1280x720.mp4 filter=lfs diff=lfs merge=lfs -text
37
  src/examples/Samplevideo[[:space:]]720x480.mp4 filter=lfs diff=lfs merge=lfs -text
 
 
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  src/examples/SampleVideo[[:space:]]1280x720.mp4 filter=lfs diff=lfs merge=lfs -text
37
  src/examples/Samplevideo[[:space:]]720x480.mp4 filter=lfs diff=lfs merge=lfs -text
38
+ src/examples/SampleVideo[[:space:]]720x480.mp4 filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -1,4 +1,5 @@
1
  .eggs/
 
2
  dist/
3
  *.pyc
4
  __pycache__/
@@ -9,4 +10,5 @@ __tmp/*
9
  .mypycache
10
  .ruff_cache
11
  node_modules
12
- backend/**/templates/
 
 
1
  .eggs/
2
+ .vscode/
3
  dist/
4
  *.pyc
5
  __pycache__/
 
10
  .mypycache
11
  .ruff_cache
12
  node_modules
13
+ backend/**/templates/
14
+ README_TEMPLATE.md
README.md CHANGED
@@ -10,9 +10,18 @@ app_file: space.py
10
  ---
11
 
12
  # `gradio_videoslider`
13
- <img alt="Static Badge" src="https://img.shields.io/badge/version%20-%200.0.1%20-%20orange">
14
 
15
- VideoSlider Component for Gradio
 
 
 
 
 
 
 
 
 
16
 
17
  ## Installation
18
 
@@ -23,7 +32,6 @@ pip install gradio_videoslider
23
  ## Usage
24
 
25
  ```python
26
- # In demo/app.py
27
  import gradio as gr
28
  from gradio_videoslider import VideoSlider
29
  import os
@@ -33,8 +41,8 @@ import os
33
  # IMPORTANT: Replace the values below with the paths to YOUR video files.
34
  #
35
  # Option A: Relative Path (if the video is in the same folder as this app.py)
36
- # video_path_1 = "video_antes.mp4"
37
- # video_path_2 = "video_depois.mp4"
38
  #
39
  # Option B: Absolute Path (the full path to the file on your computer)
40
  # Example for Windows:
@@ -48,7 +56,7 @@ video_path_1 = "examples/SampleVideo 720x480.mp4"
48
  video_path_2 = "examples/SampleVideo 1280x720.mp4"
49
 
50
 
51
- # --- 2. FUNCTION FOR THE UPLOAD EXAMPLE (remains the same) ---
52
  def process_uploaded_videos(video_inputs):
53
  """This function handles the uploaded videos."""
54
  print("Received videos from upload:", video_inputs)
@@ -64,11 +72,13 @@ with gr.Blocks() as demo:
64
  # --- TAB 1: UPLOAD EXAMPLE ---
65
  with gr.TabItem("1. Compare via Upload"):
66
  gr.Markdown("## Upload two videos to compare them side-by-side.")
67
- video_slider_input = VideoSlider(label="Your Videos", height=400, width=700)
68
  video_slider_output = VideoSlider(
69
  label="Video comparision",
70
  interactive=False,
71
- autoplay=True,
 
 
72
  loop=True,
73
  height=400,
74
  width=700
@@ -89,13 +99,15 @@ with gr.Blocks() as demo:
89
  label="Video comparision",
90
  value=(video_path_1, video_path_2),
91
  interactive=False,
 
92
  autoplay=True,
 
93
  loop=True,
94
  height=400,
95
  width=700
96
  )
97
 
98
- # Optional: A check to give a helpful error message if files are not found.
99
  if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
100
  print("---")
101
  print(f"WARNING: Could not find one or both video files.")
@@ -105,7 +117,7 @@ if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
105
  print("---")
106
 
107
  if __name__ == '__main__':
108
- demo.launch()
109
 
110
  ```
111
 
@@ -143,7 +155,7 @@ typing.Union[
143
 
144
  </td>
145
  <td align="left"><code>None</code></td>
146
- <td align="left">A tuple of two video file paths or URLs to display initially.</td>
147
  </tr>
148
 
149
  <tr>
@@ -156,7 +168,7 @@ int | None
156
 
157
  </td>
158
  <td align="left"><code>None</code></td>
159
- <td align="left">The height of the component in pixels.</td>
160
  </tr>
161
 
162
  <tr>
@@ -169,7 +181,7 @@ int | None
169
 
170
  </td>
171
  <td align="left"><code>None</code></td>
172
- <td align="left">The width of the component in pixels.</td>
173
  </tr>
174
 
175
  <tr>
@@ -182,7 +194,7 @@ str | None
182
 
183
  </td>
184
  <td align="left"><code>None</code></td>
185
- <td align="left">The label for this component.</td>
186
  </tr>
187
 
188
  <tr>
@@ -195,7 +207,7 @@ float | None
195
 
196
  </td>
197
  <td align="left"><code>None</code></td>
198
- <td align="left">None</td>
199
  </tr>
200
 
201
  <tr>
@@ -208,7 +220,7 @@ bool | None
208
 
209
  </td>
210
  <td align="left"><code>None</code></td>
211
- <td align="left">None</td>
212
  </tr>
213
 
214
  <tr>
@@ -221,7 +233,7 @@ bool
221
 
222
  </td>
223
  <td align="left"><code>True</code></td>
224
- <td align="left">None</td>
225
  </tr>
226
 
227
  <tr>
@@ -234,7 +246,7 @@ int | None
234
 
235
  </td>
236
  <td align="left"><code>None</code></td>
237
- <td align="left">None</td>
238
  </tr>
239
 
240
  <tr>
@@ -247,7 +259,7 @@ int
247
 
248
  </td>
249
  <td align="left"><code>160</code></td>
250
- <td align="left">None</td>
251
  </tr>
252
 
253
  <tr>
@@ -260,7 +272,7 @@ bool | None
260
 
261
  </td>
262
  <td align="left"><code>None</code></td>
263
- <td align="left">If False, the component will be in display-only mode.</td>
264
  </tr>
265
 
266
  <tr>
@@ -273,7 +285,7 @@ bool
273
 
274
  </td>
275
  <td align="left"><code>True</code></td>
276
- <td align="left">None</td>
277
  </tr>
278
 
279
  <tr>
@@ -286,7 +298,7 @@ str | None
286
 
287
  </td>
288
  <td align="left"><code>None</code></td>
289
- <td align="left">None</td>
290
  </tr>
291
 
292
  <tr>
@@ -301,7 +313,7 @@ typing.Union[typing.List[str], str, NoneType][
301
 
302
  </td>
303
  <td align="left"><code>None</code></td>
304
- <td align="left">None</td>
305
  </tr>
306
 
307
  <tr>
@@ -314,7 +326,7 @@ int
314
 
315
  </td>
316
  <td align="left"><code>50</code></td>
317
- <td align="left">The initial position of the slider, from 0 to 100.</td>
318
  </tr>
319
 
320
  <tr>
@@ -327,7 +339,20 @@ bool
327
 
328
  </td>
329
  <td align="left"><code>True</code></td>
330
- <td align="left">None</td>
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  </tr>
332
 
333
  <tr>
@@ -340,7 +365,20 @@ bool
340
 
341
  </td>
342
  <td align="left"><code>True</code></td>
343
- <td align="left">None</td>
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  </tr>
345
 
346
  <tr>
@@ -353,7 +391,7 @@ bool
353
 
354
  </td>
355
  <td align="left"><code>False</code></td>
356
- <td align="left">If True, the videos will start playing automatically.</td>
357
  </tr>
358
 
359
  <tr>
@@ -366,7 +404,7 @@ bool
366
 
367
  </td>
368
  <td align="left"><code>False</code></td>
369
- <td align="left">If True, the videos will loop when they finish.</td>
370
  </tr>
371
  </tbody></table>
372
 
 
10
  ---
11
 
12
  # `gradio_videoslider`
13
+ <a href="https://pypi.org/project/gradio_videoslider/" target="_blank"><img alt="PyPI - Version" src="https://img.shields.io/pypi/v/gradio_videoslider"></a>
14
 
15
+ An interactive component for Gradio to compare two videos side-by-side with a draggable slider.
16
+
17
+ ## Features
18
+
19
+ - **Side-by-Side Comparison**: Display two videos in the same component, perfect for showing "before and after" results.
20
+ - **Interactive Slider**: A draggable vertical slider allows users to intuitively compare the two videos.
21
+ - **Synchronized Playback**: Clicking on the component plays or pauses both videos simultaneously, keeping them in sync.
22
+ - **Input and Output**: Use it as an input field for users to upload two videos, or as an output to display results from your function.
23
+ - **Standard Video Controls**: Includes autoplay, looping properties, mute/unmute, fullscreen toggle, and a download button.
24
+ - **Flexible Loading**: Load videos from local file paths or remote URLs directly into the component.
25
 
26
  ## Installation
27
 
 
32
  ## Usage
33
 
34
  ```python
 
35
  import gradio as gr
36
  from gradio_videoslider import VideoSlider
37
  import os
 
41
  # IMPORTANT: Replace the values below with the paths to YOUR video files.
42
  #
43
  # Option A: Relative Path (if the video is in the same folder as this app.py)
44
+ # video_path_1 = "video_before.mp4"
45
+ # video_path_2 = "video_after.mp4"
46
  #
47
  # Option B: Absolute Path (the full path to the file on your computer)
48
  # Example for Windows:
 
56
  video_path_2 = "examples/SampleVideo 1280x720.mp4"
57
 
58
 
59
+ # --- 2. FUNCTION FOR THE UPLOAD EXAMPLE ---
60
  def process_uploaded_videos(video_inputs):
61
  """This function handles the uploaded videos."""
62
  print("Received videos from upload:", video_inputs)
 
72
  # --- TAB 1: UPLOAD EXAMPLE ---
73
  with gr.TabItem("1. Compare via Upload"):
74
  gr.Markdown("## Upload two videos to compare them side-by-side.")
75
+ video_slider_input = VideoSlider(label="Your Videos", height=400, width=700, video_mode="upload")
76
  video_slider_output = VideoSlider(
77
  label="Video comparision",
78
  interactive=False,
79
+ autoplay=True,
80
+ video_mode="preview",
81
+ show_download_button=False,
82
  loop=True,
83
  height=400,
84
  width=700
 
99
  label="Video comparision",
100
  value=(video_path_1, video_path_2),
101
  interactive=False,
102
+ show_download_button=False,
103
  autoplay=True,
104
+ video_mode="preview",
105
  loop=True,
106
  height=400,
107
  width=700
108
  )
109
 
110
+ # A check to give a helpful error message if files are not found.
111
  if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
112
  print("---")
113
  print(f"WARNING: Could not find one or both video files.")
 
117
  print("---")
118
 
119
  if __name__ == '__main__':
120
+ demo.launch(debug=True)
121
 
122
  ```
123
 
 
155
 
156
  </td>
157
  <td align="left"><code>None</code></td>
158
+ <td align="left">A tuple of two video file paths or URLs to display initially. Can also be a callable.</td>
159
  </tr>
160
 
161
  <tr>
 
168
 
169
  </td>
170
  <td align="left"><code>None</code></td>
171
+ <td align="left">The height of the component container in pixels.</td>
172
  </tr>
173
 
174
  <tr>
 
181
 
182
  </td>
183
  <td align="left"><code>None</code></td>
184
+ <td align="left">The width of the component container in pixels.</td>
185
  </tr>
186
 
187
  <tr>
 
194
 
195
  </td>
196
  <td align="left"><code>None</code></td>
197
+ <td align="left">The label for this component that appears above it.</td>
198
  </tr>
199
 
200
  <tr>
 
207
 
208
  </td>
209
  <td align="left"><code>None</code></td>
210
+ <td align="left">If `value` is a callable, run the function 'every' seconds while the client connection is open.</td>
211
  </tr>
212
 
213
  <tr>
 
220
 
221
  </td>
222
  <td align="left"><code>None</code></td>
223
+ <td align="left">If False, the label is not displayed.</td>
224
  </tr>
225
 
226
  <tr>
 
233
 
234
  </td>
235
  <td align="left"><code>True</code></td>
236
+ <td align="left">If False, the component will not be wrapped in a container.</td>
237
  </tr>
238
 
239
  <tr>
 
246
 
247
  </td>
248
  <td align="left"><code>None</code></td>
249
+ <td align="left">An integer that defines the component's relative size in a layout.</td>
250
  </tr>
251
 
252
  <tr>
 
259
 
260
  </td>
261
  <td align="left"><code>160</code></td>
262
+ <td align="left">The minimum width of the component in pixels.</td>
263
  </tr>
264
 
265
  <tr>
 
272
 
273
  </td>
274
  <td align="left"><code>None</code></td>
275
+ <td align="left">If True, the component is in input mode (upload). If False, it's in display-only mode.</td>
276
  </tr>
277
 
278
  <tr>
 
285
 
286
  </td>
287
  <td align="left"><code>True</code></td>
288
+ <td align="left">If False, the component is not rendered.</td>
289
  </tr>
290
 
291
  <tr>
 
298
 
299
  </td>
300
  <td align="left"><code>None</code></td>
301
+ <td align="left">An optional string that is assigned as the id of the component in the HTML.</td>
302
  </tr>
303
 
304
  <tr>
 
313
 
314
  </td>
315
  <td align="left"><code>None</code></td>
316
+ <td align="left">An optional list of strings that are assigned as the classes of the component in the HTML.</td>
317
  </tr>
318
 
319
  <tr>
 
326
 
327
  </td>
328
  <td align="left"><code>50</code></td>
329
+ <td align="left">The initial horizontal position of the slider, from 0 (left) to 100 (right).</td>
330
  </tr>
331
 
332
  <tr>
 
339
 
340
  </td>
341
  <td align="left"><code>True</code></td>
342
+ <td align="left">If True, a download button is shown for the second video.</td>
343
+ </tr>
344
+
345
+ <tr>
346
+ <td align="left"><code>show_mute_button</code></td>
347
+ <td align="left" style="width: 25%;">
348
+
349
+ ```python
350
+ bool
351
+ ```
352
+
353
+ </td>
354
+ <td align="left"><code>True</code></td>
355
+ <td align="left">If True, a mute/unmute button is shown.</td>
356
  </tr>
357
 
358
  <tr>
 
365
 
366
  </td>
367
  <td align="left"><code>True</code></td>
368
+ <td align="left">If True, a fullscreen button is shown.</td>
369
+ </tr>
370
+
371
+ <tr>
372
+ <td align="left"><code>video_mode</code></td>
373
+ <td align="left" style="width: 25%;">
374
+
375
+ ```python
376
+ "upload" | "preview"
377
+ ```
378
+
379
+ </td>
380
+ <td align="left"><code>"preview"</code></td>
381
+ <td align="left">The mode of the component, either "upload" or "preview".</td>
382
  </tr>
383
 
384
  <tr>
 
391
 
392
  </td>
393
  <td align="left"><code>False</code></td>
394
+ <td align="left">If True, videos will start playing automatically on load (muted).</td>
395
  </tr>
396
 
397
  <tr>
 
404
 
405
  </td>
406
  <td align="left"><code>False</code></td>
407
+ <td align="left">If True, videos will loop when they finish playing.</td>
408
  </tr>
409
  </tbody></table>
410
 
app.py CHANGED
@@ -1,83 +1,86 @@
1
- # In demo/app.py
2
- import gradio as gr
3
- from gradio_videoslider import VideoSlider
4
- import os
5
-
6
- # --- 1. DEFINE THE PATHS TO YOUR LOCAL VIDEOS ---
7
- #
8
- # IMPORTANT: Replace the values below with the paths to YOUR video files.
9
- #
10
- # Option A: Relative Path (if the video is in the same folder as this app.py)
11
- # video_path_1 = "video_antes.mp4"
12
- # video_path_2 = "video_depois.mp4"
13
- #
14
- # Option B: Absolute Path (the full path to the file on your computer)
15
- # Example for Windows:
16
- # video_path_1 = "C:\\Users\\YourName\\Videos\\my_video_1.mp4"
17
- #
18
- # Example for Linux/macOS:
19
- # video_path_1 = "/home/yourname/videos/my_video_1.mp4"
20
-
21
- # Set your file paths here:
22
- video_path_1 = "src/examples/Samplevideo 720x480.mp4"
23
- video_path_2 = "src/examples/SampleVideo 1280x720.mp4"
24
-
25
-
26
- # --- 2. FUNCTION FOR THE UPLOAD EXAMPLE (remains the same) ---
27
- def process_uploaded_videos(video_inputs):
28
- """This function handles the uploaded videos."""
29
- print("Received videos from upload:", video_inputs)
30
- return video_inputs
31
-
32
-
33
- # --- 3. GRADIO INTERFACE ---
34
- with gr.Blocks() as demo:
35
- gr.Markdown("# Video Slider Component Usage Examples")
36
- gr.Markdown("<span>💻 <a href='https://github.com/DEVAIEXP/gradio_component_videoslider'>Component GitHub Code</a></span>")
37
-
38
- with gr.Tabs():
39
- # --- TAB 1: UPLOAD EXAMPLE ---
40
- with gr.TabItem("1. Compare via Upload"):
41
- gr.Markdown("## Upload two videos to compare them side-by-side.")
42
- video_slider_input = VideoSlider(label="Your Videos", height=400, width=700)
43
- video_slider_output = VideoSlider(
44
- label="Video comparision",
45
- interactive=False,
46
- autoplay=True,
47
- loop=True,
48
- height=400,
49
- width=700
50
- )
51
- submit_btn = gr.Button("Submit")
52
- submit_btn.click(
53
- fn=process_uploaded_videos,
54
- inputs=[video_slider_input],
55
- outputs=[video_slider_output]
56
- )
57
-
58
- # --- TAB 2: LOCAL FILE EXAMPLE ---
59
- with gr.TabItem("2. Compare Local Files"):
60
- gr.Markdown("## Example with videos pre-loaded from your local disk.")
61
-
62
- # This is the key part: we pass a tuple of your local file paths to the `value` parameter.
63
- VideoSlider(
64
- label="Video comparision",
65
- value=(video_path_1, video_path_2),
66
- interactive=False,
67
- autoplay=True,
68
- loop=True,
69
- height=400,
70
- width=700
71
- )
72
-
73
- # Optional: A check to give a helpful error message if files are not found.
74
- if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
75
- print("---")
76
- print(f"WARNING: Could not find one or both video files.")
77
- print(f"Please make sure these paths are correct in your app.py file:")
78
- print(f" - '{os.path.abspath(video_path_1)}'")
79
- print(f" - '{os.path.abspath(video_path_2)}'")
80
- print("---")
81
-
82
- if __name__ == '__main__':
83
- demo.launch()
 
 
 
 
1
+ import gradio as gr
2
+ from gradio_videoslider import VideoSlider
3
+ import os
4
+
5
+ # --- 1. DEFINE THE PATHS TO YOUR LOCAL VIDEOS ---
6
+ #
7
+ # IMPORTANT: Replace the values below with the paths to YOUR video files.
8
+ #
9
+ # Option A: Relative Path (if the video is in the same folder as this app.py)
10
+ # video_path_1 = "video_before.mp4"
11
+ # video_path_2 = "video_after.mp4"
12
+ #
13
+ # Option B: Absolute Path (the full path to the file on your computer)
14
+ # Example for Windows:
15
+ # video_path_1 = "C:\\Users\\YourName\\Videos\\my_video_1.mp4"
16
+ #
17
+ # Example for Linux/macOS:
18
+ # video_path_1 = "/home/yourname/videos/my_video_1.mp4"
19
+
20
+ # Set your file paths here:
21
+ video_path_1 = "examples/SampleVideo 720x480.mp4"
22
+ video_path_2 = "examples/SampleVideo 1280x720.mp4"
23
+
24
+
25
+ # --- 2. FUNCTION FOR THE UPLOAD EXAMPLE ---
26
+ def process_uploaded_videos(video_inputs):
27
+ """This function handles the uploaded videos."""
28
+ print("Received videos from upload:", video_inputs)
29
+ return video_inputs
30
+
31
+
32
+ # --- 3. GRADIO INTERFACE ---
33
+ with gr.Blocks() as demo:
34
+ gr.Markdown("# Video Slider Component Usage Examples")
35
+ gr.Markdown("<span>💻 <a href='https://github.com/DEVAIEXP/gradio_component_videoslider'>Component GitHub Code</a></span>")
36
+
37
+ with gr.Tabs():
38
+ # --- TAB 1: UPLOAD EXAMPLE ---
39
+ with gr.TabItem("1. Compare via Upload"):
40
+ gr.Markdown("## Upload two videos to compare them side-by-side.")
41
+ video_slider_input = VideoSlider(label="Your Videos", height=400, width=700, video_mode="upload")
42
+ video_slider_output = VideoSlider(
43
+ label="Video comparision",
44
+ interactive=False,
45
+ autoplay=True,
46
+ video_mode="preview",
47
+ show_download_button=False,
48
+ loop=True,
49
+ height=400,
50
+ width=700
51
+ )
52
+ submit_btn = gr.Button("Submit")
53
+ submit_btn.click(
54
+ fn=process_uploaded_videos,
55
+ inputs=[video_slider_input],
56
+ outputs=[video_slider_output]
57
+ )
58
+
59
+ # --- TAB 2: LOCAL FILE EXAMPLE ---
60
+ with gr.TabItem("2. Compare Local Files"):
61
+ gr.Markdown("## Example with videos pre-loaded from your local disk.")
62
+
63
+ # This is the key part: we pass a tuple of your local file paths to the `value` parameter.
64
+ VideoSlider(
65
+ label="Video comparision",
66
+ value=(video_path_1, video_path_2),
67
+ interactive=False,
68
+ show_download_button=False,
69
+ autoplay=True,
70
+ video_mode="preview",
71
+ loop=True,
72
+ height=400,
73
+ width=700
74
+ )
75
+
76
+ # A check to give a helpful error message if files are not found.
77
+ if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
78
+ print("---")
79
+ print(f"WARNING: Could not find one or both video files.")
80
+ print(f"Please make sure these paths are correct in your app.py file:")
81
+ print(f" - '{os.path.abspath(video_path_1)}'")
82
+ print(f" - '{os.path.abspath(video_path_2)}'")
83
+ print("---")
84
+
85
+ if __name__ == '__main__':
86
+ demo.launch(debug=True)
space.py CHANGED
@@ -1,222 +1,225 @@
1
-
2
- import gradio as gr
3
- from app import demo as app
4
- import os
5
-
6
- _docs = {'VideoSlider': {'description': 'A custom Gradio component to display a side-by-side video comparison with a slider.\nIt can be used as both an input (uploading two videos) and an output (displaying two videos).', 'members': {'__init__': {'value': {'type': 'typing.Union[\n typing.Tuple[str | pathlib.Path, str | pathlib.Path],\n typing.Callable,\n NoneType,\n][\n typing.Tuple[str | pathlib.Path, str | pathlib.Path][\n str | pathlib.Path, str | pathlib.Path\n ],\n Callable,\n None,\n]', 'default': 'None', 'description': 'A tuple of two video file paths or URLs to display initially.'}, 'height': {'type': 'int | None', 'default': 'None', 'description': 'The height of the component in pixels.'}, 'width': {'type': 'int | None', 'default': 'None', 'description': 'The width of the component in pixels.'}, 'label': {'type': 'str | None', 'default': 'None', 'description': 'The label for this component.'}, 'every': {'type': 'float | None', 'default': 'None', 'description': None}, 'show_label': {'type': 'bool | None', 'default': 'None', 'description': None}, 'container': {'type': 'bool', 'default': 'True', 'description': None}, 'scale': {'type': 'int | None', 'default': 'None', 'description': None}, 'min_width': {'type': 'int', 'default': '160', 'description': None}, 'interactive': {'type': 'bool | None', 'default': 'None', 'description': 'If False, the component will be in display-only mode.'}, 'visible': {'type': 'bool', 'default': 'True', 'description': None}, 'elem_id': {'type': 'str | None', 'default': 'None', 'description': None}, 'elem_classes': {'type': 'typing.Union[typing.List[str], str, NoneType][\n typing.List[str][str], str, None\n]', 'default': 'None', 'description': None}, 'position': {'type': 'int', 'default': '50', 'description': 'The initial position of the slider, from 0 to 100.'}, 'show_download_button': {'type': 'bool', 'default': 'True', 'description': None}, 'show_fullscreen_button': {'type': 'bool', 'default': 'True', 'description': None}, 'autoplay': {'type': 'bool', 'default': 'False', 'description': 'If True, the videos will start playing automatically.'}, 'loop': {'type': 'bool', 'default': 'False', 'description': 'If True, the videos will loop when they finish.'}}, 'postprocess': {'value': {'type': 'typing.Optional[\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ]\n][\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ][str | pathlib.Path | None, str | pathlib.Path | None],\n None,\n]', 'description': None}}, 'preprocess': {'return': {'type': 'typing.Optional[\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ]\n][\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ][str | pathlib.Path | None, str | pathlib.Path | None],\n None,\n]', 'description': None}, 'value': None}}, 'events': {'change': {'type': None, 'default': None, 'description': 'Triggered when the value of the VideoSlider changes either because of user input (e.g. a user types in a textbox) OR because of a function update (e.g. an image receives a value from the output of an event trigger). See `.input()` for a listener that is only triggered by user input.'}, 'upload': {'type': None, 'default': None, 'description': 'This listener is triggered when the user uploads a file into the VideoSlider.'}, 'clear': {'type': None, 'default': None, 'description': 'This listener is triggered when the user clears the VideoSlider using the clear button for the component.'}}}, '__meta__': {'additional_interfaces': {}, 'user_fn_refs': {'VideoSlider': []}}}
7
-
8
- abs_path = os.path.join(os.path.dirname(__file__), "css.css")
9
-
10
- with gr.Blocks(
11
- css=abs_path,
12
- theme=gr.themes.Default(
13
- font_mono=[
14
- gr.themes.GoogleFont("Inconsolata"),
15
- "monospace",
16
- ],
17
- ),
18
- ) as demo:
19
- gr.Markdown(
20
- """
21
- # `gradio_videoslider`
22
-
23
- <div style="display: flex; gap: 7px;">
24
- <img alt="Static Badge" src="https://img.shields.io/badge/version%20-%200.0.1%20-%20orange">
25
- </div>
26
-
27
- VideoSlider Component for Gradio
28
- """, elem_classes=["md-custom"], header_links=True)
29
- app.render()
30
- gr.Markdown(
31
- """
32
- ## Installation
33
-
34
- ```bash
35
- pip install gradio_videoslider
36
- ```
37
-
38
- ## Usage
39
-
40
- ```python
41
- # In demo/app.py
42
- import gradio as gr
43
- from gradio_videoslider import VideoSlider
44
- import os
45
-
46
- # --- 1. DEFINE THE PATHS TO YOUR LOCAL VIDEOS ---
47
- #
48
- # IMPORTANT: Replace the values below with the paths to YOUR video files.
49
- #
50
- # Option A: Relative Path (if the video is in the same folder as this app.py)
51
- # video_path_1 = "video_antes.mp4"
52
- # video_path_2 = "video_depois.mp4"
53
- #
54
- # Option B: Absolute Path (the full path to the file on your computer)
55
- # Example for Windows:
56
- # video_path_1 = "C:\\Users\\YourName\\Videos\\my_video_1.mp4"
57
- #
58
- # Example for Linux/macOS:
59
- # video_path_1 = "/home/yourname/videos/my_video_1.mp4"
60
-
61
- # Set your file paths here:
62
- video_path_1 = "src/examples/Samplevideo 720x480.mp4"
63
- video_path_2 = "src/examples/SampleVideo 1280x720.mp4"
64
-
65
-
66
- # --- 2. FUNCTION FOR THE UPLOAD EXAMPLE (remains the same) ---
67
- def process_uploaded_videos(video_inputs):
68
- \"\"\"This function handles the uploaded videos.\"\"\"
69
- print("Received videos from upload:", video_inputs)
70
- return video_inputs
71
-
72
-
73
- # --- 3. GRADIO INTERFACE ---
74
- with gr.Blocks() as demo:
75
- gr.Markdown("# Video Slider Component Usage Examples")
76
- gr.Markdown("<span>💻 <a href='https://github.com/DEVAIEXP/gradio_component_videoslider'>Component GitHub Code</a></span>")
77
-
78
- with gr.Tabs():
79
- # --- TAB 1: UPLOAD EXAMPLE ---
80
- with gr.TabItem("1. Compare via Upload"):
81
- gr.Markdown("## Upload two videos to compare them side-by-side.")
82
- video_slider_input = VideoSlider(label="Your Videos", height=400, width=700)
83
- video_slider_output = VideoSlider(
84
- label="Video comparision",
85
- interactive=False,
86
- autoplay=True,
87
- loop=True,
88
- height=400,
89
- width=700
90
- )
91
- submit_btn = gr.Button("Submit")
92
- submit_btn.click(
93
- fn=process_uploaded_videos,
94
- inputs=[video_slider_input],
95
- outputs=[video_slider_output]
96
- )
97
-
98
- # --- TAB 2: LOCAL FILE EXAMPLE ---
99
- with gr.TabItem("2. Compare Local Files"):
100
- gr.Markdown("## Example with videos pre-loaded from your local disk.")
101
-
102
- # This is the key part: we pass a tuple of your local file paths to the `value` parameter.
103
- VideoSlider(
104
- label="Video comparision",
105
- value=(video_path_1, video_path_2),
106
- interactive=False,
107
- autoplay=True,
108
- loop=True,
109
- height=400,
110
- width=700
111
- )
112
-
113
- # Optional: A check to give a helpful error message if files are not found.
114
- if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
115
- print("---")
116
- print(f"WARNING: Could not find one or both video files.")
117
- print(f"Please make sure these paths are correct in your app.py file:")
118
- print(f" - '{os.path.abspath(video_path_1)}'")
119
- print(f" - '{os.path.abspath(video_path_2)}'")
120
- print("---")
121
-
122
- if __name__ == '__main__':
123
- demo.launch()
124
-
125
- ```
126
- """, elem_classes=["md-custom"], header_links=True)
127
-
128
-
129
- gr.Markdown("""
130
- ## `VideoSlider`
131
-
132
- ### Initialization
133
- """, elem_classes=["md-custom"], header_links=True)
134
-
135
- gr.ParamViewer(value=_docs["VideoSlider"]["members"]["__init__"], linkify=[])
136
-
137
-
138
- gr.Markdown("### Events")
139
- gr.ParamViewer(value=_docs["VideoSlider"]["events"], linkify=['Event'])
140
-
141
-
142
-
143
-
144
- gr.Markdown("""
145
-
146
- ### User function
147
-
148
- The impact on the users predict function varies depending on whether the component is used as an input or output for an event (or both).
149
-
150
- - When used as an Input, the component only impacts the input signature of the user function.
151
- - When used as an output, the component only impacts the return signature of the user function.
152
-
153
- The code snippet below is accurate in cases where the component is used as both an input and an output.
154
-
155
-
156
-
157
- ```python
158
- def predict(
159
- value: typing.Optional[
160
- typing.Tuple[
161
- str | pathlib.Path | None, str | pathlib.Path | None
162
- ]
163
- ][
164
- typing.Tuple[
165
- str | pathlib.Path | None, str | pathlib.Path | None
166
- ][str | pathlib.Path | None, str | pathlib.Path | None],
167
- None,
168
- ]
169
- ) -> typing.Optional[
170
- typing.Tuple[
171
- str | pathlib.Path | None, str | pathlib.Path | None
172
- ]
173
- ][
174
- typing.Tuple[
175
- str | pathlib.Path | None, str | pathlib.Path | None
176
- ][str | pathlib.Path | None, str | pathlib.Path | None],
177
- None,
178
- ]:
179
- return value
180
- ```
181
- """, elem_classes=["md-custom", "VideoSlider-user-fn"], header_links=True)
182
-
183
-
184
-
185
-
186
- demo.load(None, js=r"""function() {
187
- const refs = {};
188
- const user_fn_refs = {
189
- VideoSlider: [], };
190
- requestAnimationFrame(() => {
191
-
192
- Object.entries(user_fn_refs).forEach(([key, refs]) => {
193
- if (refs.length > 0) {
194
- const el = document.querySelector(`.${key}-user-fn`);
195
- if (!el) return;
196
- refs.forEach(ref => {
197
- el.innerHTML = el.innerHTML.replace(
198
- new RegExp("\\b"+ref+"\\b", "g"),
199
- `<a href="#h-${ref.toLowerCase()}">${ref}</a>`
200
- );
201
- })
202
- }
203
- })
204
-
205
- Object.entries(refs).forEach(([key, refs]) => {
206
- if (refs.length > 0) {
207
- const el = document.querySelector(`.${key}`);
208
- if (!el) return;
209
- refs.forEach(ref => {
210
- el.innerHTML = el.innerHTML.replace(
211
- new RegExp("\\b"+ref+"\\b", "g"),
212
- `<a href="#h-${ref.toLowerCase()}">${ref}</a>`
213
- );
214
- })
215
- }
216
- })
217
- })
218
- }
219
-
220
- """)
221
-
222
- demo.launch()
 
 
 
 
1
+
2
+ import gradio as gr
3
+ from app import demo as app
4
+ import os
5
+
6
+ _docs = {'VideoSlider': {'description': 'A custom Gradio component to display a side-by-side video comparison with a slider.\nCan be used as an input (for uploading two videos) or as an output (for displaying two videos).', 'members': {'__init__': {'value': {'type': 'typing.Union[\n typing.Tuple[str | pathlib.Path, str | pathlib.Path],\n typing.Callable,\n NoneType,\n][\n typing.Tuple[str | pathlib.Path, str | pathlib.Path][\n str | pathlib.Path, str | pathlib.Path\n ],\n Callable,\n None,\n]', 'default': 'None', 'description': 'A tuple of two video file paths or URLs to display initially. Can also be a callable.'}, 'height': {'type': 'int | None', 'default': 'None', 'description': 'The height of the component container in pixels.'}, 'width': {'type': 'int | None', 'default': 'None', 'description': 'The width of the component container in pixels.'}, 'label': {'type': 'str | None', 'default': 'None', 'description': 'The label for this component that appears above it.'}, 'every': {'type': 'float | None', 'default': 'None', 'description': "If `value` is a callable, run the function 'every' seconds while the client connection is open."}, 'show_label': {'type': 'bool | None', 'default': 'None', 'description': 'If False, the label is not displayed.'}, 'container': {'type': 'bool', 'default': 'True', 'description': 'If False, the component will not be wrapped in a container.'}, 'scale': {'type': 'int | None', 'default': 'None', 'description': "An integer that defines the component's relative size in a layout."}, 'min_width': {'type': 'int', 'default': '160', 'description': 'The minimum width of the component in pixels.'}, 'interactive': {'type': 'bool | None', 'default': 'None', 'description': "If True, the component is in input mode (upload). If False, it's in display-only mode."}, 'visible': {'type': 'bool', 'default': 'True', 'description': 'If False, the component is not rendered.'}, 'elem_id': {'type': 'str | None', 'default': 'None', 'description': 'An optional string that is assigned as the id of the component in the HTML.'}, 'elem_classes': {'type': 'typing.Union[typing.List[str], str, NoneType][\n typing.List[str][str], str, None\n]', 'default': 'None', 'description': 'An optional list of strings that are assigned as the classes of the component in the HTML.'}, 'position': {'type': 'int', 'default': '50', 'description': 'The initial horizontal position of the slider, from 0 (left) to 100 (right).'}, 'show_download_button': {'type': 'bool', 'default': 'True', 'description': 'If True, a download button is shown for the second video.'}, 'show_mute_button': {'type': 'bool', 'default': 'True', 'description': 'If True, a mute/unmute button is shown.'}, 'show_fullscreen_button': {'type': 'bool', 'default': 'True', 'description': 'If True, a fullscreen button is shown.'}, 'video_mode': {'type': '"upload" | "preview"', 'default': '"preview"', 'description': 'The mode of the component, either "upload" or "preview".'}, 'autoplay': {'type': 'bool', 'default': 'False', 'description': 'If True, videos will start playing automatically on load (muted).'}, 'loop': {'type': 'bool', 'default': 'False', 'description': 'If True, videos will loop when they finish playing.'}}, 'postprocess': {'value': {'type': 'typing.Optional[\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ]\n][\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ][str | pathlib.Path | None, str | pathlib.Path | None],\n None,\n]', 'description': None}}, 'preprocess': {'return': {'type': 'typing.Optional[\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ]\n][\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ][str | pathlib.Path | None, str | pathlib.Path | None],\n None,\n]', 'description': None}, 'value': None}}, 'events': {'change': {'type': None, 'default': None, 'description': 'Triggered when the value of the VideoSlider changes either because of user input (e.g. a user types in a textbox) OR because of a function update (e.g. an image receives a value from the output of an event trigger). See `.input()` for a listener that is only triggered by user input.'}, 'upload': {'type': None, 'default': None, 'description': 'This listener is triggered when the user uploads a file into the VideoSlider.'}, 'clear': {'type': None, 'default': None, 'description': 'This listener is triggered when the user clears the VideoSlider using the clear button for the component.'}}}, '__meta__': {'additional_interfaces': {}, 'user_fn_refs': {'VideoSlider': []}}}
7
+
8
+ abs_path = os.path.join(os.path.dirname(__file__), "css.css")
9
+
10
+ with gr.Blocks(
11
+ css=abs_path,
12
+ theme=gr.themes.Default(
13
+ font_mono=[
14
+ gr.themes.GoogleFont("Inconsolata"),
15
+ "monospace",
16
+ ],
17
+ ),
18
+ ) as demo:
19
+ gr.Markdown(
20
+ """
21
+ # `gradio_videoslider`
22
+
23
+ <div style="display: flex; gap: 7px;">
24
+ <a href="https://pypi.org/project/gradio_videoslider/" target="_blank"><img alt="PyPI - Version" src="https://img.shields.io/pypi/v/gradio_videoslider"></a>
25
+ </div>
26
+
27
+ VideoSlider Component for Gradio
28
+ """, elem_classes=["md-custom"], header_links=True)
29
+ app.render()
30
+ gr.Markdown(
31
+ """
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install gradio_videoslider
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ import gradio as gr
42
+ from gradio_videoslider import VideoSlider
43
+ import os
44
+
45
+ # --- 1. DEFINE THE PATHS TO YOUR LOCAL VIDEOS ---
46
+ #
47
+ # IMPORTANT: Replace the values below with the paths to YOUR video files.
48
+ #
49
+ # Option A: Relative Path (if the video is in the same folder as this app.py)
50
+ # video_path_1 = "video_before.mp4"
51
+ # video_path_2 = "video_after.mp4"
52
+ #
53
+ # Option B: Absolute Path (the full path to the file on your computer)
54
+ # Example for Windows:
55
+ # video_path_1 = "C:\\Users\\YourName\\Videos\\my_video_1.mp4"
56
+ #
57
+ # Example for Linux/macOS:
58
+ # video_path_1 = "/home/yourname/videos/my_video_1.mp4"
59
+
60
+ # Set your file paths here:
61
+ video_path_1 = "examples/SampleVideo 720x480.mp4"
62
+ video_path_2 = "examples/SampleVideo 1280x720.mp4"
63
+
64
+
65
+ # --- 2. FUNCTION FOR THE UPLOAD EXAMPLE ---
66
+ def process_uploaded_videos(video_inputs):
67
+ \"\"\"This function handles the uploaded videos.\"\"\"
68
+ print("Received videos from upload:", video_inputs)
69
+ return video_inputs
70
+
71
+
72
+ # --- 3. GRADIO INTERFACE ---
73
+ with gr.Blocks() as demo:
74
+ gr.Markdown("# Video Slider Component Usage Examples")
75
+ gr.Markdown("<span>💻 <a href='https://github.com/DEVAIEXP/gradio_component_videoslider'>Component GitHub Code</a></span>")
76
+
77
+ with gr.Tabs():
78
+ # --- TAB 1: UPLOAD EXAMPLE ---
79
+ with gr.TabItem("1. Compare via Upload"):
80
+ gr.Markdown("## Upload two videos to compare them side-by-side.")
81
+ video_slider_input = VideoSlider(label="Your Videos", height=400, width=700, video_mode="upload")
82
+ video_slider_output = VideoSlider(
83
+ label="Video comparision",
84
+ interactive=False,
85
+ autoplay=True,
86
+ video_mode="preview",
87
+ show_download_button=False,
88
+ loop=True,
89
+ height=400,
90
+ width=700
91
+ )
92
+ submit_btn = gr.Button("Submit")
93
+ submit_btn.click(
94
+ fn=process_uploaded_videos,
95
+ inputs=[video_slider_input],
96
+ outputs=[video_slider_output]
97
+ )
98
+
99
+ # --- TAB 2: LOCAL FILE EXAMPLE ---
100
+ with gr.TabItem("2. Compare Local Files"):
101
+ gr.Markdown("## Example with videos pre-loaded from your local disk.")
102
+
103
+ # This is the key part: we pass a tuple of your local file paths to the `value` parameter.
104
+ VideoSlider(
105
+ label="Video comparision",
106
+ value=(video_path_1, video_path_2),
107
+ interactive=False,
108
+ show_download_button=False,
109
+ autoplay=True,
110
+ video_mode="preview",
111
+ loop=True,
112
+ height=400,
113
+ width=700
114
+ )
115
+
116
+ # A check to give a helpful error message if files are not found.
117
+ if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
118
+ print("---")
119
+ print(f"WARNING: Could not find one or both video files.")
120
+ print(f"Please make sure these paths are correct in your app.py file:")
121
+ print(f" - '{os.path.abspath(video_path_1)}'")
122
+ print(f" - '{os.path.abspath(video_path_2)}'")
123
+ print("---")
124
+
125
+ if __name__ == '__main__':
126
+ demo.launch(debug=True)
127
+
128
+ ```
129
+ """, elem_classes=["md-custom"], header_links=True)
130
+
131
+
132
+ gr.Markdown("""
133
+ ## `VideoSlider`
134
+
135
+ ### Initialization
136
+ """, elem_classes=["md-custom"], header_links=True)
137
+
138
+ gr.ParamViewer(value=_docs["VideoSlider"]["members"]["__init__"], linkify=[])
139
+
140
+
141
+ gr.Markdown("### Events")
142
+ gr.ParamViewer(value=_docs["VideoSlider"]["events"], linkify=['Event'])
143
+
144
+
145
+
146
+
147
+ gr.Markdown("""
148
+
149
+ ### User function
150
+
151
+ The impact on the users predict function varies depending on whether the component is used as an input or output for an event (or both).
152
+
153
+ - When used as an Input, the component only impacts the input signature of the user function.
154
+ - When used as an output, the component only impacts the return signature of the user function.
155
+
156
+ The code snippet below is accurate in cases where the component is used as both an input and an output.
157
+
158
+
159
+
160
+ ```python
161
+ def predict(
162
+ value: typing.Optional[
163
+ typing.Tuple[
164
+ str | pathlib.Path | None, str | pathlib.Path | None
165
+ ]
166
+ ][
167
+ typing.Tuple[
168
+ str | pathlib.Path | None, str | pathlib.Path | None
169
+ ][str | pathlib.Path | None, str | pathlib.Path | None],
170
+ None,
171
+ ]
172
+ ) -> typing.Optional[
173
+ typing.Tuple[
174
+ str | pathlib.Path | None, str | pathlib.Path | None
175
+ ]
176
+ ][
177
+ typing.Tuple[
178
+ str | pathlib.Path | None, str | pathlib.Path | None
179
+ ][str | pathlib.Path | None, str | pathlib.Path | None],
180
+ None,
181
+ ]:
182
+ return value
183
+ ```
184
+ """, elem_classes=["md-custom", "VideoSlider-user-fn"], header_links=True)
185
+
186
+
187
+
188
+
189
+ demo.load(None, js=r"""function() {
190
+ const refs = {};
191
+ const user_fn_refs = {
192
+ VideoSlider: [], };
193
+ requestAnimationFrame(() => {
194
+
195
+ Object.entries(user_fn_refs).forEach(([key, refs]) => {
196
+ if (refs.length > 0) {
197
+ const el = document.querySelector(`.${key}-user-fn`);
198
+ if (!el) return;
199
+ refs.forEach(ref => {
200
+ el.innerHTML = el.innerHTML.replace(
201
+ new RegExp("\\b"+ref+"\\b", "g"),
202
+ `<a href="#h-${ref.toLowerCase()}">${ref}</a>`
203
+ );
204
+ })
205
+ }
206
+ })
207
+
208
+ Object.entries(refs).forEach(([key, refs]) => {
209
+ if (refs.length > 0) {
210
+ const el = document.querySelector(`.${key}`);
211
+ if (!el) return;
212
+ refs.forEach(ref => {
213
+ el.innerHTML = el.innerHTML.replace(
214
+ new RegExp("\\b"+ref+"\\b", "g"),
215
+ `<a href="#h-${ref.toLowerCase()}">${ref}</a>`
216
+ );
217
+ })
218
+ }
219
+ })
220
+ })
221
+ }
222
+
223
+ """)
224
+
225
+ demo.launch()
src/.gitignore CHANGED
@@ -1,4 +1,5 @@
1
  .eggs/
 
2
  dist/
3
  *.pyc
4
  __pycache__/
@@ -9,4 +10,5 @@ __tmp/*
9
  .mypycache
10
  .ruff_cache
11
  node_modules
12
- backend/**/templates/
 
 
1
  .eggs/
2
+ .vscode/
3
  dist/
4
  *.pyc
5
  __pycache__/
 
10
  .mypycache
11
  .ruff_cache
12
  node_modules
13
+ backend/**/templates/
14
+ README_TEMPLATE.md
src/.vscode/launch.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ // Use IntelliSense to learn about possible attributes.
3
+ // Hover to view descriptions of existing attributes.
4
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5
+ "version": "0.2.0",
6
+ "configurations": [
7
+
8
+ {
9
+ "name": "Python Debugger: Current File",
10
+ "type": "debugpy",
11
+ "request": "launch",
12
+ "program": "${file}",
13
+ "console": "integratedTerminal"
14
+ }
15
+ ]
16
+ }
src/README.md CHANGED
@@ -10,9 +10,18 @@ app_file: space.py
10
  ---
11
 
12
  # `gradio_videoslider`
13
- <img alt="Static Badge" src="https://img.shields.io/badge/version%20-%200.0.1%20-%20orange">
14
 
15
- VideoSlider Component for Gradio
 
 
 
 
 
 
 
 
 
16
 
17
  ## Installation
18
 
@@ -23,7 +32,6 @@ pip install gradio_videoslider
23
  ## Usage
24
 
25
  ```python
26
- # In demo/app.py
27
  import gradio as gr
28
  from gradio_videoslider import VideoSlider
29
  import os
@@ -33,8 +41,8 @@ import os
33
  # IMPORTANT: Replace the values below with the paths to YOUR video files.
34
  #
35
  # Option A: Relative Path (if the video is in the same folder as this app.py)
36
- # video_path_1 = "video_antes.mp4"
37
- # video_path_2 = "video_depois.mp4"
38
  #
39
  # Option B: Absolute Path (the full path to the file on your computer)
40
  # Example for Windows:
@@ -48,7 +56,7 @@ video_path_1 = "examples/SampleVideo 720x480.mp4"
48
  video_path_2 = "examples/SampleVideo 1280x720.mp4"
49
 
50
 
51
- # --- 2. FUNCTION FOR THE UPLOAD EXAMPLE (remains the same) ---
52
  def process_uploaded_videos(video_inputs):
53
  """This function handles the uploaded videos."""
54
  print("Received videos from upload:", video_inputs)
@@ -64,11 +72,13 @@ with gr.Blocks() as demo:
64
  # --- TAB 1: UPLOAD EXAMPLE ---
65
  with gr.TabItem("1. Compare via Upload"):
66
  gr.Markdown("## Upload two videos to compare them side-by-side.")
67
- video_slider_input = VideoSlider(label="Your Videos", height=400, width=700)
68
  video_slider_output = VideoSlider(
69
  label="Video comparision",
70
  interactive=False,
71
- autoplay=True,
 
 
72
  loop=True,
73
  height=400,
74
  width=700
@@ -89,13 +99,15 @@ with gr.Blocks() as demo:
89
  label="Video comparision",
90
  value=(video_path_1, video_path_2),
91
  interactive=False,
 
92
  autoplay=True,
 
93
  loop=True,
94
  height=400,
95
  width=700
96
  )
97
 
98
- # Optional: A check to give a helpful error message if files are not found.
99
  if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
100
  print("---")
101
  print(f"WARNING: Could not find one or both video files.")
@@ -105,7 +117,7 @@ if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
105
  print("---")
106
 
107
  if __name__ == '__main__':
108
- demo.launch()
109
 
110
  ```
111
 
@@ -143,7 +155,7 @@ typing.Union[
143
 
144
  </td>
145
  <td align="left"><code>None</code></td>
146
- <td align="left">A tuple of two video file paths or URLs to display initially.</td>
147
  </tr>
148
 
149
  <tr>
@@ -156,7 +168,7 @@ int | None
156
 
157
  </td>
158
  <td align="left"><code>None</code></td>
159
- <td align="left">The height of the component in pixels.</td>
160
  </tr>
161
 
162
  <tr>
@@ -169,7 +181,7 @@ int | None
169
 
170
  </td>
171
  <td align="left"><code>None</code></td>
172
- <td align="left">The width of the component in pixels.</td>
173
  </tr>
174
 
175
  <tr>
@@ -182,7 +194,7 @@ str | None
182
 
183
  </td>
184
  <td align="left"><code>None</code></td>
185
- <td align="left">The label for this component.</td>
186
  </tr>
187
 
188
  <tr>
@@ -195,7 +207,7 @@ float | None
195
 
196
  </td>
197
  <td align="left"><code>None</code></td>
198
- <td align="left">None</td>
199
  </tr>
200
 
201
  <tr>
@@ -208,7 +220,7 @@ bool | None
208
 
209
  </td>
210
  <td align="left"><code>None</code></td>
211
- <td align="left">None</td>
212
  </tr>
213
 
214
  <tr>
@@ -221,7 +233,7 @@ bool
221
 
222
  </td>
223
  <td align="left"><code>True</code></td>
224
- <td align="left">None</td>
225
  </tr>
226
 
227
  <tr>
@@ -234,7 +246,7 @@ int | None
234
 
235
  </td>
236
  <td align="left"><code>None</code></td>
237
- <td align="left">None</td>
238
  </tr>
239
 
240
  <tr>
@@ -247,7 +259,7 @@ int
247
 
248
  </td>
249
  <td align="left"><code>160</code></td>
250
- <td align="left">None</td>
251
  </tr>
252
 
253
  <tr>
@@ -260,7 +272,7 @@ bool | None
260
 
261
  </td>
262
  <td align="left"><code>None</code></td>
263
- <td align="left">If False, the component will be in display-only mode.</td>
264
  </tr>
265
 
266
  <tr>
@@ -273,7 +285,7 @@ bool
273
 
274
  </td>
275
  <td align="left"><code>True</code></td>
276
- <td align="left">None</td>
277
  </tr>
278
 
279
  <tr>
@@ -286,7 +298,7 @@ str | None
286
 
287
  </td>
288
  <td align="left"><code>None</code></td>
289
- <td align="left">None</td>
290
  </tr>
291
 
292
  <tr>
@@ -301,7 +313,7 @@ typing.Union[typing.List[str], str, NoneType][
301
 
302
  </td>
303
  <td align="left"><code>None</code></td>
304
- <td align="left">None</td>
305
  </tr>
306
 
307
  <tr>
@@ -314,7 +326,7 @@ int
314
 
315
  </td>
316
  <td align="left"><code>50</code></td>
317
- <td align="left">The initial position of the slider, from 0 to 100.</td>
318
  </tr>
319
 
320
  <tr>
@@ -327,7 +339,20 @@ bool
327
 
328
  </td>
329
  <td align="left"><code>True</code></td>
330
- <td align="left">None</td>
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  </tr>
332
 
333
  <tr>
@@ -340,7 +365,20 @@ bool
340
 
341
  </td>
342
  <td align="left"><code>True</code></td>
343
- <td align="left">None</td>
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  </tr>
345
 
346
  <tr>
@@ -353,7 +391,7 @@ bool
353
 
354
  </td>
355
  <td align="left"><code>False</code></td>
356
- <td align="left">If True, the videos will start playing automatically.</td>
357
  </tr>
358
 
359
  <tr>
@@ -366,7 +404,7 @@ bool
366
 
367
  </td>
368
  <td align="left"><code>False</code></td>
369
- <td align="left">If True, the videos will loop when they finish.</td>
370
  </tr>
371
  </tbody></table>
372
 
 
10
  ---
11
 
12
  # `gradio_videoslider`
13
+ <a href="https://pypi.org/project/gradio_videoslider/" target="_blank"><img alt="PyPI - Version" src="https://img.shields.io/pypi/v/gradio_videoslider"></a>
14
 
15
+ An interactive component for Gradio to compare two videos side-by-side with a draggable slider.
16
+
17
+ ## Features
18
+
19
+ - **Side-by-Side Comparison**: Display two videos in the same component, perfect for showing "before and after" results.
20
+ - **Interactive Slider**: A draggable vertical slider allows users to intuitively compare the two videos.
21
+ - **Synchronized Playback**: Clicking on the component plays or pauses both videos simultaneously, keeping them in sync.
22
+ - **Input and Output**: Use it as an input field for users to upload two videos, or as an output to display results from your function.
23
+ - **Standard Video Controls**: Includes autoplay, looping properties, mute/unmute, fullscreen toggle, and a download button.
24
+ - **Flexible Loading**: Load videos from local file paths or remote URLs directly into the component.
25
 
26
  ## Installation
27
 
 
32
  ## Usage
33
 
34
  ```python
 
35
  import gradio as gr
36
  from gradio_videoslider import VideoSlider
37
  import os
 
41
  # IMPORTANT: Replace the values below with the paths to YOUR video files.
42
  #
43
  # Option A: Relative Path (if the video is in the same folder as this app.py)
44
+ # video_path_1 = "video_before.mp4"
45
+ # video_path_2 = "video_after.mp4"
46
  #
47
  # Option B: Absolute Path (the full path to the file on your computer)
48
  # Example for Windows:
 
56
  video_path_2 = "examples/SampleVideo 1280x720.mp4"
57
 
58
 
59
+ # --- 2. FUNCTION FOR THE UPLOAD EXAMPLE ---
60
  def process_uploaded_videos(video_inputs):
61
  """This function handles the uploaded videos."""
62
  print("Received videos from upload:", video_inputs)
 
72
  # --- TAB 1: UPLOAD EXAMPLE ---
73
  with gr.TabItem("1. Compare via Upload"):
74
  gr.Markdown("## Upload two videos to compare them side-by-side.")
75
+ video_slider_input = VideoSlider(label="Your Videos", height=400, width=700, video_mode="upload")
76
  video_slider_output = VideoSlider(
77
  label="Video comparision",
78
  interactive=False,
79
+ autoplay=True,
80
+ video_mode="preview",
81
+ show_download_button=False,
82
  loop=True,
83
  height=400,
84
  width=700
 
99
  label="Video comparision",
100
  value=(video_path_1, video_path_2),
101
  interactive=False,
102
+ show_download_button=False,
103
  autoplay=True,
104
+ video_mode="preview",
105
  loop=True,
106
  height=400,
107
  width=700
108
  )
109
 
110
+ # A check to give a helpful error message if files are not found.
111
  if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
112
  print("---")
113
  print(f"WARNING: Could not find one or both video files.")
 
117
  print("---")
118
 
119
  if __name__ == '__main__':
120
+ demo.launch(debug=True)
121
 
122
  ```
123
 
 
155
 
156
  </td>
157
  <td align="left"><code>None</code></td>
158
+ <td align="left">A tuple of two video file paths or URLs to display initially. Can also be a callable.</td>
159
  </tr>
160
 
161
  <tr>
 
168
 
169
  </td>
170
  <td align="left"><code>None</code></td>
171
+ <td align="left">The height of the component container in pixels.</td>
172
  </tr>
173
 
174
  <tr>
 
181
 
182
  </td>
183
  <td align="left"><code>None</code></td>
184
+ <td align="left">The width of the component container in pixels.</td>
185
  </tr>
186
 
187
  <tr>
 
194
 
195
  </td>
196
  <td align="left"><code>None</code></td>
197
+ <td align="left">The label for this component that appears above it.</td>
198
  </tr>
199
 
200
  <tr>
 
207
 
208
  </td>
209
  <td align="left"><code>None</code></td>
210
+ <td align="left">If `value` is a callable, run the function 'every' seconds while the client connection is open.</td>
211
  </tr>
212
 
213
  <tr>
 
220
 
221
  </td>
222
  <td align="left"><code>None</code></td>
223
+ <td align="left">If False, the label is not displayed.</td>
224
  </tr>
225
 
226
  <tr>
 
233
 
234
  </td>
235
  <td align="left"><code>True</code></td>
236
+ <td align="left">If False, the component will not be wrapped in a container.</td>
237
  </tr>
238
 
239
  <tr>
 
246
 
247
  </td>
248
  <td align="left"><code>None</code></td>
249
+ <td align="left">An integer that defines the component's relative size in a layout.</td>
250
  </tr>
251
 
252
  <tr>
 
259
 
260
  </td>
261
  <td align="left"><code>160</code></td>
262
+ <td align="left">The minimum width of the component in pixels.</td>
263
  </tr>
264
 
265
  <tr>
 
272
 
273
  </td>
274
  <td align="left"><code>None</code></td>
275
+ <td align="left">If True, the component is in input mode (upload). If False, it's in display-only mode.</td>
276
  </tr>
277
 
278
  <tr>
 
285
 
286
  </td>
287
  <td align="left"><code>True</code></td>
288
+ <td align="left">If False, the component is not rendered.</td>
289
  </tr>
290
 
291
  <tr>
 
298
 
299
  </td>
300
  <td align="left"><code>None</code></td>
301
+ <td align="left">An optional string that is assigned as the id of the component in the HTML.</td>
302
  </tr>
303
 
304
  <tr>
 
313
 
314
  </td>
315
  <td align="left"><code>None</code></td>
316
+ <td align="left">An optional list of strings that are assigned as the classes of the component in the HTML.</td>
317
  </tr>
318
 
319
  <tr>
 
326
 
327
  </td>
328
  <td align="left"><code>50</code></td>
329
+ <td align="left">The initial horizontal position of the slider, from 0 (left) to 100 (right).</td>
330
  </tr>
331
 
332
  <tr>
 
339
 
340
  </td>
341
  <td align="left"><code>True</code></td>
342
+ <td align="left">If True, a download button is shown for the second video.</td>
343
+ </tr>
344
+
345
+ <tr>
346
+ <td align="left"><code>show_mute_button</code></td>
347
+ <td align="left" style="width: 25%;">
348
+
349
+ ```python
350
+ bool
351
+ ```
352
+
353
+ </td>
354
+ <td align="left"><code>True</code></td>
355
+ <td align="left">If True, a mute/unmute button is shown.</td>
356
  </tr>
357
 
358
  <tr>
 
365
 
366
  </td>
367
  <td align="left"><code>True</code></td>
368
+ <td align="left">If True, a fullscreen button is shown.</td>
369
+ </tr>
370
+
371
+ <tr>
372
+ <td align="left"><code>video_mode</code></td>
373
+ <td align="left" style="width: 25%;">
374
+
375
+ ```python
376
+ "upload" | "preview"
377
+ ```
378
+
379
+ </td>
380
+ <td align="left"><code>"preview"</code></td>
381
+ <td align="left">The mode of the component, either "upload" or "preview".</td>
382
  </tr>
383
 
384
  <tr>
 
391
 
392
  </td>
393
  <td align="left"><code>False</code></td>
394
+ <td align="left">If True, videos will start playing automatically on load (muted).</td>
395
  </tr>
396
 
397
  <tr>
 
404
 
405
  </td>
406
  <td align="left"><code>False</code></td>
407
+ <td align="left">If True, videos will loop when they finish playing.</td>
408
  </tr>
409
  </tbody></table>
410
 
src/backend/gradio_videoslider/templates/component/index.js CHANGED
The diff for this file is too large to render. See raw diff
 
src/backend/gradio_videoslider/templates/component/style.css CHANGED
@@ -1 +1 @@
1
- .block.svelte-239wnu{position:relative;margin:0;box-shadow:var(--block-shadow);border-width:var(--block-border-width);border-color:var(--block-border-color);border-radius:var(--block-radius);background:var(--block-background-fill);width:100%;line-height:var(--line-sm)}.block.fullscreen.svelte-239wnu{border-radius:0}.auto-margin.svelte-239wnu{margin-left:auto;margin-right:auto}.block.border_focus.svelte-239wnu{border-color:var(--color-accent)}.block.border_contrast.svelte-239wnu{border-color:var(--body-text-color)}.padded.svelte-239wnu{padding:var(--block-padding)}.hidden.svelte-239wnu{display:none}.flex.svelte-239wnu{display:flex;flex-direction:column}.hide-container.svelte-239wnu:not(.fullscreen){margin:0;box-shadow:none;--block-border-width:0;background:transparent;padding:0;overflow:visible}.resize-handle.svelte-239wnu{position:absolute;bottom:0;right:0;width:10px;height:10px;fill:var(--block-border-color);cursor:nwse-resize}.fullscreen.svelte-239wnu{position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1000;overflow:auto}.animating.svelte-239wnu{animation:svelte-239wnu-pop-out .1s ease-out forwards}@keyframes svelte-239wnu-pop-out{0%{position:fixed;top:var(--start-top);left:var(--start-left);width:var(--start-width);height:var(--start-height);z-index:100}to{position:fixed;top:0vh;left:0vw;width:100vw;height:100vh;z-index:1000}}.placeholder.svelte-239wnu{border-radius:var(--block-radius);border-width:var(--block-border-width);border-color:var(--block-border-color);border-style:dashed}Tables */ table,tr,td,th{margin-top:var(--spacing-sm);margin-bottom:var(--spacing-sm);padding:var(--spacing-xl)}.md code,.md pre{background:none;font-family:var(--font-mono);font-size:var(--text-sm);text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:2;tab-size:2;-webkit-hyphens:none;hyphens:none}.md pre[class*=language-]::selection,.md pre[class*=language-] ::selection,.md code[class*=language-]::selection,.md code[class*=language-] ::selection{text-shadow:none;background:#b3d4fc}.md pre{padding:1em;margin:.5em 0;overflow:auto;position:relative;margin-top:var(--spacing-sm);margin-bottom:var(--spacing-sm);box-shadow:none;border:none;border-radius:var(--radius-md);background:var(--code-background-fill);padding:var(--spacing-xxl);font-family:var(--font-mono);text-shadow:none;border-radius:var(--radius-sm);white-space:nowrap;display:block;white-space:pre}.md :not(pre)>code{padding:.1em;border-radius:var(--radius-xs);white-space:normal;background:var(--code-background-fill);border:1px solid var(--panel-border-color);padding:var(--spacing-xxs) var(--spacing-xs)}.md .token.comment,.md .token.prolog,.md .token.doctype,.md .token.cdata{color:#708090}.md .token.punctuation{color:#999}.md .token.namespace{opacity:.7}.md .token.property,.md .token.tag,.md .token.boolean,.md .token.number,.md .token.constant,.md .token.symbol,.md .token.deleted{color:#905}.md .token.selector,.md .token.attr-name,.md .token.string,.md .token.char,.md .token.builtin,.md .token.inserted{color:#690}.md .token.atrule,.md .token.attr-value,.md .token.keyword{color:#07a}.md .token.function,.md .token.class-name{color:#dd4a68}.md .token.regex,.md .token.important,.md .token.variable{color:#e90}.md .token.important,.md .token.bold{font-weight:700}.md .token.italic{font-style:italic}.md .token.entity{cursor:help}.dark .md .token.comment,.dark .md .token.prolog,.dark .md .token.cdata{color:#5c6370}.dark .md .token.doctype,.dark .md .token.punctuation,.dark .md .token.entity{color:#abb2bf}.dark .md .token.attr-name,.dark .md .token.class-name,.dark .md .token.boolean,.dark .md .token.constant,.dark .md .token.number,.dark .md .token.atrule{color:#d19a66}.dark .md .token.keyword{color:#c678dd}.dark .md .token.property,.dark .md .token.tag,.dark .md .token.symbol,.dark .md .token.deleted,.dark .md .token.important{color:#e06c75}.dark .md .token.selector,.dark .md .token.string,.dark .md .token.char,.dark .md .token.builtin,.dark .md .token.inserted,.dark .md .token.regex,.dark .md .token.attr-value,.dark .md .token.attr-value>.token.punctuation{color:#98c379}.dark .md .token.variable,.dark .md .token.operator,.dark .md .token.function{color:#61afef}.dark .md .token.url{color:#56b6c2}span.svelte-1m32c2s div[class*=code_wrap]{position:relative}span.svelte-1m32c2s span.katex{font-size:var(--text-lg);direction:ltr}span.svelte-1m32c2s div[class*=code_wrap]>button{z-index:1;cursor:pointer;border-bottom-left-radius:var(--radius-sm);padding:var(--spacing-md);width:25px;height:25px;position:absolute;right:0}span.svelte-1m32c2s .check{opacity:0;z-index:var(--layer-top);transition:opacity .2s;background:var(--code-background-fill);color:var(--body-text-color);position:absolute;top:var(--size-1-5);left:var(--size-1-5)}span.svelte-1m32c2s p:not(:first-child){margin-top:var(--spacing-xxl)}span.svelte-1m32c2s .md-header-anchor{margin-left:-25px;padding-right:8px;line-height:1;color:var(--body-text-color-subdued);opacity:0}span.svelte-1m32c2s h1:hover .md-header-anchor,span.svelte-1m32c2s h2:hover .md-header-anchor,span.svelte-1m32c2s h3:hover .md-header-anchor,span.svelte-1m32c2s h4:hover .md-header-anchor,span.svelte-1m32c2s h5:hover .md-header-anchor,span.svelte-1m32c2s h6:hover .md-header-anchor{opacity:1}span.md.svelte-1m32c2s .md-header-anchor>svg{color:var(--body-text-color-subdued)}span.svelte-1m32c2s table{word-break:break-word}div.svelte-17qq50w>.md.prose{font-weight:var(--block-info-text-weight);font-size:var(--block-info-text-size);line-height:var(--line-sm)}div.svelte-17qq50w>.md.prose *{color:var(--block-info-text-color)}div.svelte-17qq50w{margin-bottom:var(--spacing-md)}span.has-info.svelte-zgrq3{margin-bottom:var(--spacing-xs)}span.svelte-zgrq3:not(.has-info){margin-bottom:var(--spacing-lg)}span.svelte-zgrq3{display:inline-block;position:relative;z-index:var(--layer-4);border:solid var(--block-title-border-width) var(--block-title-border-color);border-radius:var(--block-title-radius);background:var(--block-title-background-fill);padding:var(--block-title-padding);color:var(--block-title-text-color);font-weight:var(--block-title-text-weight);font-size:var(--block-title-text-size);line-height:var(--line-sm)}span[dir=rtl].svelte-zgrq3{display:block}.hide.svelte-zgrq3{margin:0;height:0}label.svelte-13ao5pu.svelte-13ao5pu{display:inline-flex;align-items:center;z-index:var(--layer-2);box-shadow:var(--block-label-shadow);border:var(--block-label-border-width) solid var(--block-label-border-color);border-top:none;border-left:none;border-radius:var(--block-label-radius);background:var(--block-label-background-fill);padding:var(--block-label-padding);pointer-events:none;color:var(--block-label-text-color);font-weight:var(--block-label-text-weight);font-size:var(--block-label-text-size);line-height:var(--line-sm)}.gr-group label.svelte-13ao5pu.svelte-13ao5pu{border-top-left-radius:0}label.float.svelte-13ao5pu.svelte-13ao5pu{position:absolute;top:var(--block-label-margin);left:var(--block-label-margin)}label.svelte-13ao5pu.svelte-13ao5pu:not(.float){position:static;margin-top:var(--block-label-margin);margin-left:var(--block-label-margin)}.hide.svelte-13ao5pu.svelte-13ao5pu{height:0}span.svelte-13ao5pu.svelte-13ao5pu{opacity:.8;margin-right:var(--size-2);width:calc(var(--block-label-text-size) - 1px);height:calc(var(--block-label-text-size) - 1px)}.hide-label.svelte-13ao5pu.svelte-13ao5pu{box-shadow:none;border-width:0;background:transparent;overflow:visible}label[dir=rtl].svelte-13ao5pu.svelte-13ao5pu{border:var(--block-label-border-width) solid var(--block-label-border-color);border-top:none;border-right:none;border-bottom-left-radius:var(--block-radius);border-bottom-right-radius:var(--block-label-radius);border-top-left-radius:var(--block-label-radius)}label[dir=rtl].svelte-13ao5pu span.svelte-13ao5pu{margin-left:var(--size-2);margin-right:0}button.svelte-qgco6m{display:flex;justify-content:center;align-items:center;gap:1px;z-index:var(--layer-2);border-radius:var(--radius-xs);color:var(--block-label-text-color);border:1px solid transparent;padding:var(--spacing-xxs)}button.svelte-qgco6m:hover{background-color:var(--background-fill-secondary)}button[disabled].svelte-qgco6m{opacity:.5;box-shadow:none}button[disabled].svelte-qgco6m:hover{cursor:not-allowed}.padded.svelte-qgco6m{background:var(--bg-color)}button.svelte-qgco6m:hover,button.highlight.svelte-qgco6m{cursor:pointer;color:var(--color-accent)}.padded.svelte-qgco6m:hover{color:var(--block-label-text-color)}span.svelte-qgco6m{padding:0 1px;font-size:10px}div.svelte-qgco6m{display:flex;align-items:center;justify-content:center;transition:filter .2s ease-in-out}.x-small.svelte-qgco6m{width:10px;height:10px}.small.svelte-qgco6m{width:14px;height:14px}.medium.svelte-qgco6m{width:20px;height:20px}.large.svelte-qgco6m{width:22px;height:22px}.pending.svelte-qgco6m{animation:svelte-qgco6m-flash .5s infinite}@keyframes svelte-qgco6m-flash{0%{opacity:.5}50%{opacity:1}to{opacity:.5}}.transparent.svelte-qgco6m{background:transparent;border:none;box-shadow:none}.empty.svelte-3w3rth{display:flex;justify-content:center;align-items:center;margin-top:calc(0px - var(--size-6));height:var(--size-full)}.icon.svelte-3w3rth{opacity:.5;height:var(--size-5);color:var(--body-text-color)}.small.svelte-3w3rth{min-height:calc(var(--size-32) - 20px)}.large.svelte-3w3rth{min-height:calc(var(--size-64) - 20px)}.unpadded_box.svelte-3w3rth{margin-top:0}.small_parent.svelte-3w3rth{min-height:100%!important}.dropdown-arrow.svelte-145leq6,.dropdown-arrow.svelte-ihhdbf{fill:currentColor}.circle.svelte-ihhdbf{fill:currentColor;opacity:.1}svg.svelte-pb9pol{animation:svelte-pb9pol-spin 1.5s linear infinite}@keyframes svelte-pb9pol-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}h2.svelte-1xg7h5n{font-size:var(--text-xl)!important}p.svelte-1xg7h5n,h2.svelte-1xg7h5n{white-space:pre-line}.wrap.svelte-1xg7h5n{display:flex;flex-direction:column;justify-content:center;align-items:center;min-height:var(--size-60);color:var(--block-label-text-color);line-height:var(--line-md);height:100%;padding-top:var(--size-3);text-align:center;margin:auto var(--spacing-lg)}.or.svelte-1xg7h5n{color:var(--body-text-color-subdued);display:flex}.icon-wrap.svelte-1xg7h5n{width:30px;margin-bottom:var(--spacing-lg)}@media (--screen-md){.wrap.svelte-1xg7h5n{font-size:var(--text-lg)}}.hovered.svelte-1xg7h5n{color:var(--color-accent)}div.svelte-q32hvf{border-top:1px solid transparent;display:flex;max-height:100%;justify-content:center;align-items:center;gap:var(--spacing-sm);height:auto;align-items:flex-end;color:var(--block-label-text-color);flex-shrink:0}.show_border.svelte-q32hvf{border-top:1px solid var(--block-border-color);margin-top:var(--spacing-xxl);box-shadow:var(--shadow-drop)}.source-selection.svelte-15ls1gu{display:flex;align-items:center;justify-content:center;border-top:1px solid var(--border-color-primary);width:100%;margin-left:auto;margin-right:auto;height:var(--size-10)}.icon.svelte-15ls1gu{width:22px;height:22px;margin:var(--spacing-lg) var(--spacing-xs);padding:var(--spacing-xs);color:var(--neutral-400);border-radius:var(--radius-md)}.selected.svelte-15ls1gu{color:var(--color-accent)}.icon.svelte-15ls1gu:hover,.icon.svelte-15ls1gu:focus{color:var(--color-accent)}.icon-button-wrapper.svelte-109se4{display:flex;flex-direction:row;align-items:center;justify-content:center;z-index:var(--layer-3);gap:var(--spacing-sm);box-shadow:var(--shadow-drop);border:1px solid var(--border-color-primary);background:var(--block-background-fill);padding:var(--spacing-xxs)}.icon-button-wrapper.hide-top-corner.svelte-109se4{border-top:none;border-right:none;border-radius:var(--block-label-right-radius)}.icon-button-wrapper.display-top-corner.svelte-109se4{border-radius:var(--radius-sm) 0 0 var(--radius-sm);top:var(--spacing-sm);right:-1px}.icon-button-wrapper.svelte-109se4:not(.top-panel){border:1px solid var(--border-color-primary);border-radius:var(--radius-sm)}.top-panel.svelte-109se4{position:absolute;top:var(--block-label-margin);right:var(--block-label-margin);margin:0}.icon-button-wrapper.svelte-109se4 button{margin:var(--spacing-xxs);border-radius:var(--radius-xs);position:relative}.icon-button-wrapper.svelte-109se4 a.download-link:not(:last-child),.icon-button-wrapper.svelte-109se4 button:not(:last-child){margin-right:var(--spacing-xxs)}.icon-button-wrapper.svelte-109se4 a.download-link:not(:last-child):not(.no-border *):after,.icon-button-wrapper.svelte-109se4 button:not(:last-child):not(.no-border *):after{content:"";position:absolute;right:-4.5px;top:15%;height:70%;width:1px;background-color:var(--border-color-primary)}.icon-button-wrapper.svelte-109se4>*{height:100%}svg.svelte-43sxxs.svelte-43sxxs{width:var(--size-20);height:var(--size-20)}svg.svelte-43sxxs path.svelte-43sxxs{fill:var(--loader-color)}div.svelte-43sxxs.svelte-43sxxs{z-index:var(--layer-2)}.margin.svelte-43sxxs.svelte-43sxxs{margin:var(--size-4)}.wrap.svelte-17v219f.svelte-17v219f{display:flex;flex-direction:column;justify-content:center;align-items:center;z-index:var(--layer-2);transition:opacity .1s ease-in-out;border-radius:var(--block-radius);background:var(--block-background-fill);padding:0 var(--size-6);max-height:var(--size-screen-h);overflow:hidden}.wrap.center.svelte-17v219f.svelte-17v219f{top:0;right:0;left:0}.wrap.default.svelte-17v219f.svelte-17v219f{top:0;right:0;bottom:0;left:0}.hide.svelte-17v219f.svelte-17v219f{opacity:0;pointer-events:none}.generating.svelte-17v219f.svelte-17v219f{animation:svelte-17v219f-pulseStart 1s cubic-bezier(.4,0,.6,1),svelte-17v219f-pulse 2s cubic-bezier(.4,0,.6,1) 1s infinite;border:2px solid var(--color-accent);background:transparent;z-index:var(--layer-1);pointer-events:none}.translucent.svelte-17v219f.svelte-17v219f{background:none}@keyframes svelte-17v219f-pulseStart{0%{opacity:0}to{opacity:1}}@keyframes svelte-17v219f-pulse{0%,to{opacity:1}50%{opacity:.5}}.loading.svelte-17v219f.svelte-17v219f{z-index:var(--layer-2);color:var(--body-text-color)}.eta-bar.svelte-17v219f.svelte-17v219f{position:absolute;top:0;right:0;bottom:0;left:0;transform-origin:left;opacity:.8;z-index:var(--layer-1);transition:10ms;background:var(--background-fill-secondary)}.progress-bar-wrap.svelte-17v219f.svelte-17v219f{border:1px solid var(--border-color-primary);background:var(--background-fill-primary);width:55.5%;height:var(--size-4)}.progress-bar.svelte-17v219f.svelte-17v219f{transform-origin:left;background-color:var(--loader-color);width:var(--size-full);height:var(--size-full)}.progress-level.svelte-17v219f.svelte-17v219f{display:flex;flex-direction:column;align-items:center;gap:1;z-index:var(--layer-2);width:var(--size-full)}.progress-level-inner.svelte-17v219f.svelte-17v219f{margin:var(--size-2) auto;color:var(--body-text-color);font-size:var(--text-sm);font-family:var(--font-mono)}.meta-text.svelte-17v219f.svelte-17v219f{position:absolute;bottom:0;right:0;z-index:var(--layer-2);padding:var(--size-1) var(--size-2);font-size:var(--text-sm);font-family:var(--font-mono)}.meta-text-center.svelte-17v219f.svelte-17v219f{display:flex;position:absolute;top:0;right:0;justify-content:center;align-items:center;transform:translateY(var(--size-6));z-index:var(--layer-2);padding:var(--size-1) var(--size-2);font-size:var(--text-sm);font-family:var(--font-mono);text-align:center}.error.svelte-17v219f.svelte-17v219f{box-shadow:var(--shadow-drop);border:solid 1px var(--error-border-color);border-radius:var(--radius-full);background:var(--error-background-fill);padding-right:var(--size-4);padding-left:var(--size-4);color:var(--error-text-color);font-weight:var(--weight-semibold);font-size:var(--text-lg);line-height:var(--line-lg);font-family:var(--font)}.minimal.svelte-17v219f.svelte-17v219f{pointer-events:none}.minimal.svelte-17v219f .progress-text.svelte-17v219f{background:var(--block-background-fill)}.border.svelte-17v219f.svelte-17v219f{border:1px solid var(--border-color-primary)}.clear-status.svelte-17v219f.svelte-17v219f{position:absolute;display:flex;top:var(--size-2);right:var(--size-2);justify-content:flex-end;gap:var(--spacing-sm);z-index:var(--layer-1)}.toast-body.svelte-1pgj5gs{display:flex;position:relative;right:0;left:0;align-items:center;margin:var(--size-6) var(--size-4);margin:auto;border-radius:var(--container-radius);overflow:hidden;pointer-events:auto}.toast-body.error.svelte-1pgj5gs{border:1px solid var(--color-red-700);background:var(--color-red-50)}.dark .toast-body.error.svelte-1pgj5gs{border:1px solid var(--color-red-500);background-color:var(--color-grey-950)}.toast-body.warning.svelte-1pgj5gs{border:1px solid var(--color-yellow-700);background:var(--color-yellow-50)}.dark .toast-body.warning.svelte-1pgj5gs{border:1px solid var(--color-yellow-500);background-color:var(--color-grey-950)}.toast-body.info.svelte-1pgj5gs{border:1px solid var(--color-grey-700);background:var (--color-grey-50)}.dark .toast-body.info.svelte-1pgj5gs{border:1px solid var(--color-grey-500);background-color:var(--color-grey-950)}.toast-body.success.svelte-1pgj5gs{border:1px solid var(--color-green-700);background:var(--color-green-50)}.dark .toast-body.success.svelte-1pgj5gs{border:1px solid var(--color-green-500);background-color:var(--color-grey-950)}.toast-title.svelte-1pgj5gs{display:flex;align-items:center;font-weight:var(--weight-bold);font-size:var(--text-lg);line-height:var(--line-sm)}.toast-title.error.svelte-1pgj5gs{color:var(--color-red-700)}.dark .toast-title.error.svelte-1pgj5gs{color:var(--color-red-50)}.toast-title.warning.svelte-1pgj5gs{color:var(--color-yellow-700)}.dark .toast-title.warning.svelte-1pgj5gs{color:var(--color-yellow-50)}.toast-title.info.svelte-1pgj5gs{color:var(--color-grey-700)}.dark .toast-title.info.svelte-1pgj5gs{color:var(--color-grey-50)}.toast-title.success.svelte-1pgj5gs{color:var(--color-green-700)}.dark .toast-title.success.svelte-1pgj5gs{color:var(--color-green-50)}.toast-close.svelte-1pgj5gs{margin:0 var(--size-3);border-radius:var(--size-3);padding:0px var(--size-1-5);font-size:var(--size-5);line-height:var(--size-5)}.toast-close.error.svelte-1pgj5gs{color:var(--color-red-700)}.dark .toast-close.error.svelte-1pgj5gs{color:var(--color-red-500)}.toast-close.warning.svelte-1pgj5gs{color:var(--color-yellow-700)}.dark .toast-close.warning.svelte-1pgj5gs{color:var(--color-yellow-500)}.toast-close.info.svelte-1pgj5gs{color:var(--color-grey-700)}.dark .toast-close.info.svelte-1pgj5gs{color:var(--color-grey-500)}.toast-close.success.svelte-1pgj5gs{color:var(--color-green-700)}.dark .toast-close.success.svelte-1pgj5gs{color:var(--color-green-500)}.toast-text.svelte-1pgj5gs{font-size:var(--text-lg);word-wrap:break-word;overflow-wrap:break-word;word-break:break-word}.toast-text.error.svelte-1pgj5gs{color:var(--color-red-700)}.dark .toast-text.error.svelte-1pgj5gs{color:var(--color-red-50)}.toast-text.warning.svelte-1pgj5gs{color:var(--color-yellow-700)}.dark .toast-text.warning.svelte-1pgj5gs{color:var(--color-yellow-50)}.toast-text.info.svelte-1pgj5gs{color:var(--color-grey-700)}.dark .toast-text.info.svelte-1pgj5gs{color:var(--color-grey-50)}.toast-text.success.svelte-1pgj5gs{color:var(--color-green-700)}.dark .toast-text.success.svelte-1pgj5gs{color:var(--color-green-50)}.toast-details.svelte-1pgj5gs{margin:var(--size-3) var(--size-3) var(--size-3) 0;width:100%}.toast-icon.svelte-1pgj5gs{display:flex;position:absolute;position:relative;flex-shrink:0;justify-content:center;align-items:center;margin:var(--size-2);border-radius:var(--radius-full);padding:var(--size-1);padding-left:calc(var(--size-1) - 1px);width:35px;height:35px}.toast-icon.error.svelte-1pgj5gs{color:var(--color-red-700)}.dark .toast-icon.error.svelte-1pgj5gs{color:var(--color-red-500)}.toast-icon.warning.svelte-1pgj5gs{color:var(--color-yellow-700)}.dark .toast-icon.warning.svelte-1pgj5gs{color:var(--color-yellow-500)}.toast-icon.info.svelte-1pgj5gs{color:var(--color-grey-700)}.dark .toast-icon.info.svelte-1pgj5gs{color:var(--color-grey-500)}.toast-icon.success.svelte-1pgj5gs{color:var(--color-green-700)}.dark .toast-icon.success.svelte-1pgj5gs{color:var(--color-green-500)}@keyframes svelte-1pgj5gs-countdown{0%{transform:scaleX(1)}to{transform:scaleX(0)}}.timer.svelte-1pgj5gs{position:absolute;bottom:0;left:0;transform-origin:0 0;animation:svelte-1pgj5gs-countdown 10s linear forwards;width:100%;height:var(--size-1)}.timer.error.svelte-1pgj5gs{background:var(--color-red-700)}.dark .timer.error.svelte-1pgj5gs{background:var(--color-red-500)}.timer.warning.svelte-1pgj5gs{background:var(--color-yellow-700)}.dark .timer.warning.svelte-1pgj5gs{background:var(--color-yellow-500)}.timer.info.svelte-1pgj5gs{background:var(--color-grey-700)}.dark .timer.info.svelte-1pgj5gs{background:var(--color-grey-500)}.timer.success.svelte-1pgj5gs{background:var(--color-green-700)}.dark .timer.success.svelte-1pgj5gs{background:var(--color-green-500)}.hidden.svelte-1pgj5gs{display:none}.toast-text.svelte-1pgj5gs a{text-decoration:underline}.toast-wrap.svelte-gatr8h{display:flex;position:fixed;top:var(--size-4);right:var(--size-4);flex-direction:column;align-items:end;gap:var(--size-2);z-index:var(--layer-top);width:calc(100% - var(--size-8))}@media (--screen-sm){.toast-wrap.svelte-gatr8h{width:calc(var(--size-96) + var(--size-10))}}.streaming-bar.svelte-ga0jj6{position:absolute;bottom:0;left:0;right:0;height:4px;background-color:var(--primary-600);animation:svelte-ga0jj6-countdown linear forwards;z-index:1}@keyframes svelte-ga0jj6-countdown{0%{transform:translate(0)}to{transform:translate(-100%)}}.wrap.svelte-12ucyq4.svelte-12ucyq4{position:relative;width:100%;height:100%;z-index:var(--layer-1);overflow:hidden}.icon-wrap.svelte-12ucyq4.svelte-12ucyq4{display:block;position:absolute;top:50%;transform:translate(-20.5px,-50%);left:10px;width:40px;transition:.2s;color:var(--body-text-color);height:30px;border-radius:5px;background-color:var(--color-accent);display:flex;align-items:center;justify-content:center;z-index:var(--layer-3);box-shadow:0 0 5px 2px #0000004d;font-size:12px}.icon.left.svelte-12ucyq4.svelte-12ucyq4{transform:rotate(135deg);text-shadow:-1px -1px 1px rgba(0,0,0,.1)}.icon.right.svelte-12ucyq4.svelte-12ucyq4{transform:rotate(-45deg);text-shadow:-1px -1px 1px rgba(0,0,0,.1)}.icon.center.svelte-12ucyq4.svelte-12ucyq4{display:block;width:1px;height:100%;background-color:var(--color);opacity:.1}.icon-wrap.active.svelte-12ucyq4.svelte-12ucyq4,.icon-wrap.disabled.svelte-12ucyq4.svelte-12ucyq4{opacity:0}.outer.svelte-12ucyq4.svelte-12ucyq4{width:20px;height:100%;position:absolute;cursor:grab;top:0;left:-10px;pointer-events:auto;z-index:var(--layer-2)}.grab.svelte-12ucyq4.svelte-12ucyq4{cursor:grabbing}.inner.svelte-12ucyq4.svelte-12ucyq4{width:1px;height:100%;background:var(--color);position:absolute;left:calc((100% - 2px)/2)}.disabled.svelte-12ucyq4.svelte-12ucyq4{cursor:auto}.disabled.svelte-12ucyq4 .inner.svelte-12ucyq4{box-shadow:none}.content.svelte-12ucyq4.svelte-12ucyq4{width:100%;height:100%;display:flex;justify-content:center;align-items:center}.unstyled-link.svelte-151nsdd{all:unset;cursor:pointer}.overlay.svelte-1pwzuub{position:absolute;background-color:#0006;width:100%;height:100%}.hidden.svelte-1pwzuub{display:none}.load-wrap.svelte-1pwzuub{display:flex;justify-content:center;align-items:center;height:100%}.loader.svelte-1pwzuub{display:flex;position:relative;background-color:var(--border-color-accent-subdued);animation:svelte-1pwzuub-shadowPulse 2s linear infinite;box-shadow:-24px 0 var(--border-color-accent-subdued),24px 0 var(--border-color-accent-subdued);margin:var(--spacing-md);border-radius:50%;width:10px;height:10px;scale:.5}@keyframes svelte-1pwzuub-shadowPulse{33%{box-shadow:-24px 0 var(--border-color-accent-subdued),24px 0 #fff;background:#fff}66%{box-shadow:-24px 0 #fff,24px 0 #fff;background:var(--border-color-accent-subdued)}to{box-shadow:-24px 0 #fff,24px 0 var(--border-color-accent-subdued);background:#fff}}.load-wrap.svelte-kn3uji{display:flex;justify-content:center;align-items:center;height:100%}.loader.svelte-kn3uji{display:flex;position:relative;background-color:var(--border-color-accent-subdued);animation:svelte-kn3uji-shadowPulse 2s linear infinite;box-shadow:-24px 0 var(--border-color-accent-subdued),24px 0 var(--border-color-accent-subdued);margin:var(--spacing-md);border-radius:50%;width:10px;height:10px;scale:.5}@keyframes svelte-kn3uji-shadowPulse{33%{box-shadow:-24px 0 var(--border-color-accent-subdued),24px 0 #fff;background:#fff}66%{box-shadow:-24px 0 #fff,24px 0 #fff;background:var(--border-color-accent-subdued)}to{box-shadow:-24px 0 #fff,24px 0 var(--border-color-accent-subdued);background:#fff}}.container.svelte-kn3uji{display:flex;flex-direction:column;align-items:center;justify-content:center;margin:var(--spacing-lg) var(--spacing-lg) 0 var(--spacing-lg)}#timeline.svelte-kn3uji{display:flex;height:var(--size-10);flex:1;position:relative}img.svelte-kn3uji{flex:1 1 auto;min-width:0;object-fit:cover;height:var(--size-12);border:1px solid var(--block-border-color);-webkit-user-select:none;user-select:none;z-index:1}.handle.svelte-kn3uji{width:3px;background-color:var(--color-accent);cursor:ew-resize;height:var(--size-12);z-index:3;position:absolute}.opaque-layer.svelte-kn3uji{background-color:#e6672840;border:1px solid var(--color-accent);height:var(--size-12);position:absolute;z-index:2}.wrap.svelte-cr2edf.svelte-cr2edf{overflow-y:auto;transition:opacity .5s ease-in-out;background:var(--block-background-fill);position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:var(--size-40);width:var(--size-full)}.wrap.svelte-cr2edf.svelte-cr2edf:after{content:"";position:absolute;top:0;left:0;width:var(--upload-progress-width);height:100%;transition:all .5s ease-in-out;z-index:1}.uploading.svelte-cr2edf.svelte-cr2edf{font-size:var(--text-lg);font-family:var(--font);z-index:2}.file-name.svelte-cr2edf.svelte-cr2edf{margin:var(--spacing-md);font-size:var(--text-lg);color:var(--body-text-color-subdued)}.file.svelte-cr2edf.svelte-cr2edf{font-size:var(--text-md);z-index:2;display:flex;align-items:center}.file.svelte-cr2edf progress.svelte-cr2edf{display:inline;height:var(--size-1);width:100%;transition:all .5s ease-in-out;color:var(--color-accent);border:none}.file.svelte-cr2edf progress[value].svelte-cr2edf::-webkit-progress-value{background-color:var(--color-accent);border-radius:20px}.file.svelte-cr2edf progress[value].svelte-cr2edf::-webkit-progress-bar{background-color:var(--border-color-accent);border-radius:20px}.progress-bar.svelte-cr2edf.svelte-cr2edf{width:14px;height:14px;border-radius:50%;background:radial-gradient(closest-side,var(--block-background-fill) 64%,transparent 53% 100%),conic-gradient(var(--color-accent) var(--upload-progress-width),var(--border-color-accent) 0);transition:all .5s ease-in-out}button.svelte-1o7nwih{cursor:pointer;width:var(--size-full)}.center.svelte-1o7nwih{display:flex;justify-content:center}.flex.svelte-1o7nwih{display:flex;flex-direction:column;justify-content:center;align-items:center}.hidden.svelte-1o7nwih{display:none;position:absolute;flex-grow:0}.hidden.svelte-1o7nwih svg{display:none}.disable_click.svelte-1o7nwih{cursor:default}.icon-mode.svelte-1o7nwih{position:absolute!important;width:var(--size-4);height:var(--size-4);padding:0;min-height:0;border-radius:var(--radius-circle)}.icon-mode.svelte-1o7nwih svg{width:var(--size-4);height:var(--size-4)}.container.svelte-uu53fr.svelte-uu53fr{width:100%}time.svelte-uu53fr.svelte-uu53fr{color:var(--color-accent);font-weight:700;padding-left:var(--spacing-xs)}.timeline-wrapper.svelte-uu53fr.svelte-uu53fr{display:flex;align-items:center;justify-content:center;width:100%}.text-button.svelte-uu53fr.svelte-uu53fr{border:1px solid var(--neutral-400);border-radius:var(--radius-sm);font-weight:300;font-size:var(--size-3);text-align:center;color:var(--neutral-400);height:var(--size-5);font-weight:700;padding:0 5px;margin-left:5px}.text-button.svelte-uu53fr.svelte-uu53fr:hover,.text-button.svelte-uu53fr.svelte-uu53fr:focus{color:var(--color-accent);border-color:var(--color-accent)}.controls.svelte-uu53fr.svelte-uu53fr{display:flex;justify-content:space-between;align-items:center;margin:var(--spacing-lg);overflow:hidden}.edit-buttons.svelte-uu53fr.svelte-uu53fr{display:flex;gap:var(--spacing-sm)}@media (max-width: 320px){.controls.svelte-uu53fr.svelte-uu53fr{flex-direction:column;align-items:flex-start}.edit-buttons.svelte-uu53fr.svelte-uu53fr{margin-top:var(--spacing-sm)}.controls.svelte-uu53fr .svelte-uu53fr{margin:var(--spacing-sm)}.controls.svelte-uu53fr .text-button.svelte-uu53fr{margin-left:0}}.container.svelte-uu53fr.svelte-uu53fr{display:flex;flex-direction:column}.hidden.svelte-uu53fr.svelte-uu53fr{display:none}span.svelte-yzke89.svelte-yzke89{text-shadow:0 0 8px rgba(0,0,0,.5)}progress.svelte-yzke89.svelte-yzke89{margin-right:var(--size-3);border-radius:var(--radius-sm);width:var(--size-full);height:var(--size-2)}progress.svelte-yzke89.svelte-yzke89::-webkit-progress-bar{border-radius:2px;background-color:#fff3;overflow:hidden}progress.svelte-yzke89.svelte-yzke89::-webkit-progress-value{background-color:#ffffffe6}.mirror.svelte-yzke89.svelte-yzke89{transform:scaleX(-1)}.mirror-wrap.svelte-yzke89.svelte-yzke89{position:relative;height:100%;width:100%}.controls.svelte-yzke89.svelte-yzke89{position:absolute;bottom:0;opacity:0;transition:.5s;margin:var(--size-2);border-radius:var(--radius-md);background:var(--color-grey-800);padding:var(--size-2) var(--size-1);width:calc(100% - var(--size-2) * 2)}.wrap.svelte-yzke89:hover .controls.svelte-yzke89{opacity:1}.inner.svelte-yzke89.svelte-yzke89{display:flex;justify-content:space-between;align-items:center;padding-right:var(--size-2);padding-left:var(--size-2);width:var(--size-full);height:var(--size-full)}.icon.svelte-yzke89.svelte-yzke89{display:flex;justify-content:center;cursor:pointer;width:var(--size-6);color:#fff}.time.svelte-yzke89.svelte-yzke89{flex-shrink:0;margin-right:var(--size-3);margin-left:var(--size-3);color:#fff;font-size:var(--text-sm);font-family:var(--font-mono)}.wrap.svelte-yzke89.svelte-yzke89{position:relative;background-color:var(--background-fill-secondary);height:var(--size-full);width:var(--size-full);border-radius:var(--radius-xl)}.wrap.svelte-yzke89 video{height:var(--size-full);width:var(--size-full)}.video-container.svelte-4wejaf{height:100%;width:100%;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden}.main-wrapper.svelte-4wejaf{position:relative;width:100%;height:100%;cursor:pointer}.player-wrapper.svelte-4wejaf{position:absolute;top:0;left:0;width:100%;height:100%}.player-wrapper.fixed.svelte-4wejaf{background:var(--block-background-fill)}.main-wrapper>.wrap{position:absolute;top:0;left:0;z-index:10;cursor:default}img.svelte-kxeri3{object-fit:cover}.image-container.svelte-x2tujq.svelte-x2tujq{height:100%;position:relative;min-width:var(--size-20)}.image-container.svelte-x2tujq button.svelte-x2tujq{width:var(--size-full);height:var(--size-full);border-radius:var(--radius-lg);display:flex;align-items:center;justify-content:center}.image-frame.svelte-x2tujq img{width:var(--size-full);height:var(--size-full);object-fit:scale-down}.selectable.svelte-x2tujq.svelte-x2tujq{cursor:crosshair}.fullscreen-controls svg{position:relative;top:0}.image-container:fullscreen{background-color:#000;display:flex;justify-content:center;align-items:center}.image-container:fullscreen img{max-width:90vw;max-height:90vh;object-fit:scale-down}.image-frame.svelte-x2tujq.svelte-x2tujq{width:auto;height:100%;display:flex;align-items:center;justify-content:center}button.svelte-fjcd9c{cursor:pointer;width:var(--size-full)}.wrap.svelte-fjcd9c{display:flex;flex-direction:column;justify-content:center;align-items:center;min-height:var(--size-60);color:var(--block-label-text-color);height:100%;padding-top:var(--size-3)}.icon-wrap.svelte-fjcd9c{width:30px;margin-bottom:var(--spacing-lg)}@media (--screen-md){.wrap.svelte-fjcd9c{font-size:var(--text-lg)}}.wrap.svelte-1tdan5a.svelte-1tdan5a{position:relative;width:var(--size-full);height:var(--size-full)}.hide.svelte-1tdan5a.svelte-1tdan5a{display:none}video.svelte-1tdan5a.svelte-1tdan5a{width:var(--size-full);height:var(--size-full);object-fit:contain}.button-wrap.svelte-1tdan5a.svelte-1tdan5a{position:absolute;background-color:var(--block-background-fill);border:1px solid var(--border-color-primary);padding:var(--size-1-5);display:flex;bottom:var(--size-2);left:50%;transform:translate(-50%);box-shadow:var(--shadow-drop-lg);border-radius:var(--radius-xl);line-height:var(--size-3);color:var(--button-secondary-text-color)}.icon-with-text.svelte-1tdan5a.svelte-1tdan5a{width:var(--size-20);align-items:center;margin:0 var(--spacing-xl);display:flex;justify-content:space-evenly}@media (--screen-md){button.svelte-1tdan5a.svelte-1tdan5a{bottom:var(--size-4)}}@media (--screen-xl){button.svelte-1tdan5a.svelte-1tdan5a{bottom:var(--size-8)}}.icon.svelte-1tdan5a.svelte-1tdan5a{width:18px;height:18px;display:flex;justify-content:space-between;align-items:center}.color-primary.svelte-1tdan5a.svelte-1tdan5a{fill:var(--primary-600);stroke:var(--primary-600);color:var(--primary-600)}.flip.svelte-1tdan5a.svelte-1tdan5a{transform:scaleX(-1)}.select-wrap.svelte-1tdan5a.svelte-1tdan5a{-webkit-appearance:none;-moz-appearance:none;appearance:none;color:var(--button-secondary-text-color);background-color:transparent;width:95%;font-size:var(--text-md);position:absolute;bottom:var(--size-2);background-color:var(--block-background-fill);box-shadow:var(--shadow-drop-lg);border-radius:var(--radius-xl);z-index:var(--layer-top);border:1px solid var(--border-color-primary);text-align:left;line-height:var(--size-4);white-space:nowrap;text-overflow:ellipsis;left:50%;transform:translate(-50%);max-width:var(--size-52)}.select-wrap.svelte-1tdan5a>option.svelte-1tdan5a{padding:.25rem .5rem;border-bottom:1px solid var(--border-color-accent);padding-right:var(--size-8);text-overflow:ellipsis;overflow:hidden}.select-wrap.svelte-1tdan5a>option.svelte-1tdan5a:hover{background-color:var(--color-accent)}.select-wrap.svelte-1tdan5a>option.svelte-1tdan5a:last-child{border:none}.inset-icon.svelte-1tdan5a.svelte-1tdan5a{position:absolute;top:5px;right:-6.5px;width:var(--size-10);height:var(--size-5);opacity:.8}@media (--screen-md){.wrap.svelte-1tdan5a.svelte-1tdan5a{font-size:var(--text-lg)}}.image-frame.svelte-1562gr6 img{width:var(--size-full);height:var(--size-full);object-fit:scale-down}.upload-container.svelte-1562gr6{display:flex;align-items:center;justify-content:center;height:100%;flex-shrink:1;max-height:100%}.reduced-height.svelte-1562gr6{height:calc(100% - var(--size-10))}.image-container.svelte-1562gr6{display:flex;height:100%;flex-direction:column;justify-content:center;align-items:center;max-height:100%}.selectable.svelte-1562gr6{cursor:crosshair}.image-frame.svelte-1562gr6{object-fit:cover;width:100%;height:100%}.container.svelte-1sgcyba img{width:100%;height:100%}.container.selected.svelte-1sgcyba{border-color:var(--border-color-accent)}.border.table.svelte-1sgcyba{border:2px solid var(--border-color-primary)}.container.table.svelte-1sgcyba{margin:0 auto;border-radius:var(--radius-lg);overflow:hidden;width:var(--size-20);height:var(--size-20);object-fit:cover}.container.gallery.svelte-1sgcyba{width:var(--size-20);max-width:var(--size-20);object-fit:cover}.file-name.svelte-1kyjvp4{padding:var(--size-6);font-size:var(--text-xxl);word-break:break-all}.file-size.svelte-1kyjvp4{padding:var(--size-2);font-size:var(--text-xl)}.upload-container.svelte-1kyjvp4{height:100%;width:100%}.video-container.svelte-1kyjvp4{display:flex;height:100%;flex-direction:column;justify-content:center;align-items:center}.container.svelte-9k2sta{display:flex;flex-direction:row;gap:var(--spacing-lg);width:100%;height:100%}.video-slot.svelte-9k2sta{flex:1;display:flex;justify-content:center;align-items:center;min-height:var(--size-60);border:1px solid var(--border-color-primary);border-radius:var(--radius-lg);overflow:hidden;position:relative}.video-slot .upload-text{color:var(--body-text-color-subdued);text-align:center}
 
1
+ .block.svelte-239wnu{position:relative;margin:0;box-shadow:var(--block-shadow);border-width:var(--block-border-width);border-color:var(--block-border-color);border-radius:var(--block-radius);background:var(--block-background-fill);width:100%;line-height:var(--line-sm)}.block.fullscreen.svelte-239wnu{border-radius:0}.auto-margin.svelte-239wnu{margin-left:auto;margin-right:auto}.block.border_focus.svelte-239wnu{border-color:var(--color-accent)}.block.border_contrast.svelte-239wnu{border-color:var(--body-text-color)}.padded.svelte-239wnu{padding:var(--block-padding)}.hidden.svelte-239wnu{display:none}.flex.svelte-239wnu{display:flex;flex-direction:column}.hide-container.svelte-239wnu:not(.fullscreen){margin:0;box-shadow:none;--block-border-width:0;background:transparent;padding:0;overflow:visible}.resize-handle.svelte-239wnu{position:absolute;bottom:0;right:0;width:10px;height:10px;fill:var(--block-border-color);cursor:nwse-resize}.fullscreen.svelte-239wnu{position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1000;overflow:auto}.animating.svelte-239wnu{animation:svelte-239wnu-pop-out .1s ease-out forwards}@keyframes svelte-239wnu-pop-out{0%{position:fixed;top:var(--start-top);left:var(--start-left);width:var(--start-width);height:var(--start-height);z-index:100}to{position:fixed;top:0vh;left:0vw;width:100vw;height:100vh;z-index:1000}}.placeholder.svelte-239wnu{border-radius:var(--block-radius);border-width:var(--block-border-width);border-color:var(--block-border-color);border-style:dashed}Tables */ table,tr,td,th{margin-top:var(--spacing-sm);margin-bottom:var(--spacing-sm);padding:var(--spacing-xl)}.md code,.md pre{background:none;font-family:var(--font-mono);font-size:var(--text-sm);text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:2;tab-size:2;-webkit-hyphens:none;hyphens:none}.md pre[class*=language-]::selection,.md pre[class*=language-] ::selection,.md code[class*=language-]::selection,.md code[class*=language-] ::selection{text-shadow:none;background:#b3d4fc}.md pre{padding:1em;margin:.5em 0;overflow:auto;position:relative;margin-top:var(--spacing-sm);margin-bottom:var(--spacing-sm);box-shadow:none;border:none;border-radius:var(--radius-md);background:var(--code-background-fill);padding:var(--spacing-xxl);font-family:var(--font-mono);text-shadow:none;border-radius:var(--radius-sm);white-space:nowrap;display:block;white-space:pre}.md :not(pre)>code{padding:.1em;border-radius:var(--radius-xs);white-space:normal;background:var(--code-background-fill);border:1px solid var(--panel-border-color);padding:var(--spacing-xxs) var(--spacing-xs)}.md .token.comment,.md .token.prolog,.md .token.doctype,.md .token.cdata{color:#708090}.md .token.punctuation{color:#999}.md .token.namespace{opacity:.7}.md .token.property,.md .token.tag,.md .token.boolean,.md .token.number,.md .token.constant,.md .token.symbol,.md .token.deleted{color:#905}.md .token.selector,.md .token.attr-name,.md .token.string,.md .token.char,.md .token.builtin,.md .token.inserted{color:#690}.md .token.atrule,.md .token.attr-value,.md .token.keyword{color:#07a}.md .token.function,.md .token.class-name{color:#dd4a68}.md .token.regex,.md .token.important,.md .token.variable{color:#e90}.md .token.important,.md .token.bold{font-weight:700}.md .token.italic{font-style:italic}.md .token.entity{cursor:help}.dark .md .token.comment,.dark .md .token.prolog,.dark .md .token.cdata{color:#5c6370}.dark .md .token.doctype,.dark .md .token.punctuation,.dark .md .token.entity{color:#abb2bf}.dark .md .token.attr-name,.dark .md .token.class-name,.dark .md .token.boolean,.dark .md .token.constant,.dark .md .token.number,.dark .md .token.atrule{color:#d19a66}.dark .md .token.keyword{color:#c678dd}.dark .md .token.property,.dark .md .token.tag,.dark .md .token.symbol,.dark .md .token.deleted,.dark .md .token.important{color:#e06c75}.dark .md .token.selector,.dark .md .token.string,.dark .md .token.char,.dark .md .token.builtin,.dark .md .token.inserted,.dark .md .token.regex,.dark .md .token.attr-value,.dark .md .token.attr-value>.token.punctuation{color:#98c379}.dark .md .token.variable,.dark .md .token.operator,.dark .md .token.function{color:#61afef}.dark .md .token.url{color:#56b6c2}span.svelte-1m32c2s div[class*=code_wrap]{position:relative}span.svelte-1m32c2s span.katex{font-size:var(--text-lg);direction:ltr}span.svelte-1m32c2s div[class*=code_wrap]>button{z-index:1;cursor:pointer;border-bottom-left-radius:var(--radius-sm);padding:var(--spacing-md);width:25px;height:25px;position:absolute;right:0}span.svelte-1m32c2s .check{opacity:0;z-index:var(--layer-top);transition:opacity .2s;background:var(--code-background-fill);color:var(--body-text-color);position:absolute;top:var(--size-1-5);left:var(--size-1-5)}span.svelte-1m32c2s p:not(:first-child){margin-top:var(--spacing-xxl)}span.svelte-1m32c2s .md-header-anchor{margin-left:-25px;padding-right:8px;line-height:1;color:var(--body-text-color-subdued);opacity:0}span.svelte-1m32c2s h1:hover .md-header-anchor,span.svelte-1m32c2s h2:hover .md-header-anchor,span.svelte-1m32c2s h3:hover .md-header-anchor,span.svelte-1m32c2s h4:hover .md-header-anchor,span.svelte-1m32c2s h5:hover .md-header-anchor,span.svelte-1m32c2s h6:hover .md-header-anchor{opacity:1}span.md.svelte-1m32c2s .md-header-anchor>svg{color:var(--body-text-color-subdued)}span.svelte-1m32c2s table{word-break:break-word}div.svelte-17qq50w>.md.prose{font-weight:var(--block-info-text-weight);font-size:var(--block-info-text-size);line-height:var(--line-sm)}div.svelte-17qq50w>.md.prose *{color:var(--block-info-text-color)}div.svelte-17qq50w{margin-bottom:var(--spacing-md)}span.has-info.svelte-zgrq3{margin-bottom:var(--spacing-xs)}span.svelte-zgrq3:not(.has-info){margin-bottom:var(--spacing-lg)}span.svelte-zgrq3{display:inline-block;position:relative;z-index:var(--layer-4);border:solid var(--block-title-border-width) var(--block-title-border-color);border-radius:var(--block-title-radius);background:var(--block-title-background-fill);padding:var(--block-title-padding);color:var(--block-title-text-color);font-weight:var(--block-title-text-weight);font-size:var(--block-title-text-size);line-height:var(--line-sm)}span[dir=rtl].svelte-zgrq3{display:block}.hide.svelte-zgrq3{margin:0;height:0}label.svelte-13ao5pu.svelte-13ao5pu{display:inline-flex;align-items:center;z-index:var(--layer-2);box-shadow:var(--block-label-shadow);border:var(--block-label-border-width) solid var(--block-label-border-color);border-top:none;border-left:none;border-radius:var(--block-label-radius);background:var(--block-label-background-fill);padding:var(--block-label-padding);pointer-events:none;color:var(--block-label-text-color);font-weight:var(--block-label-text-weight);font-size:var(--block-label-text-size);line-height:var(--line-sm)}.gr-group label.svelte-13ao5pu.svelte-13ao5pu{border-top-left-radius:0}label.float.svelte-13ao5pu.svelte-13ao5pu{position:absolute;top:var(--block-label-margin);left:var(--block-label-margin)}label.svelte-13ao5pu.svelte-13ao5pu:not(.float){position:static;margin-top:var(--block-label-margin);margin-left:var(--block-label-margin)}.hide.svelte-13ao5pu.svelte-13ao5pu{height:0}span.svelte-13ao5pu.svelte-13ao5pu{opacity:.8;margin-right:var(--size-2);width:calc(var(--block-label-text-size) - 1px);height:calc(var(--block-label-text-size) - 1px)}.hide-label.svelte-13ao5pu.svelte-13ao5pu{box-shadow:none;border-width:0;background:transparent;overflow:visible}label[dir=rtl].svelte-13ao5pu.svelte-13ao5pu{border:var(--block-label-border-width) solid var(--block-label-border-color);border-top:none;border-right:none;border-bottom-left-radius:var(--block-radius);border-bottom-right-radius:var(--block-label-radius);border-top-left-radius:var(--block-label-radius)}label[dir=rtl].svelte-13ao5pu span.svelte-13ao5pu{margin-left:var(--size-2);margin-right:0}button.svelte-qgco6m{display:flex;justify-content:center;align-items:center;gap:1px;z-index:var(--layer-2);border-radius:var(--radius-xs);color:var(--block-label-text-color);border:1px solid transparent;padding:var(--spacing-xxs)}button.svelte-qgco6m:hover{background-color:var(--background-fill-secondary)}button[disabled].svelte-qgco6m{opacity:.5;box-shadow:none}button[disabled].svelte-qgco6m:hover{cursor:not-allowed}.padded.svelte-qgco6m{background:var(--bg-color)}button.svelte-qgco6m:hover,button.highlight.svelte-qgco6m{cursor:pointer;color:var(--color-accent)}.padded.svelte-qgco6m:hover{color:var(--block-label-text-color)}span.svelte-qgco6m{padding:0 1px;font-size:10px}div.svelte-qgco6m{display:flex;align-items:center;justify-content:center;transition:filter .2s ease-in-out}.x-small.svelte-qgco6m{width:10px;height:10px}.small.svelte-qgco6m{width:14px;height:14px}.medium.svelte-qgco6m{width:20px;height:20px}.large.svelte-qgco6m{width:22px;height:22px}.pending.svelte-qgco6m{animation:svelte-qgco6m-flash .5s infinite}@keyframes svelte-qgco6m-flash{0%{opacity:.5}50%{opacity:1}to{opacity:.5}}.transparent.svelte-qgco6m{background:transparent;border:none;box-shadow:none}.empty.svelte-3w3rth{display:flex;justify-content:center;align-items:center;margin-top:calc(0px - var(--size-6));height:var(--size-full)}.icon.svelte-3w3rth{opacity:.5;height:var(--size-5);color:var(--body-text-color)}.small.svelte-3w3rth{min-height:calc(var(--size-32) - 20px)}.large.svelte-3w3rth{min-height:calc(var(--size-64) - 20px)}.unpadded_box.svelte-3w3rth{margin-top:0}.small_parent.svelte-3w3rth{min-height:100%!important}.dropdown-arrow.svelte-145leq6,.dropdown-arrow.svelte-ihhdbf{fill:currentColor}.circle.svelte-ihhdbf{fill:currentColor;opacity:.1}svg.svelte-pb9pol{animation:svelte-pb9pol-spin 1.5s linear infinite}@keyframes svelte-pb9pol-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}h2.svelte-1xg7h5n{font-size:var(--text-xl)!important}p.svelte-1xg7h5n,h2.svelte-1xg7h5n{white-space:pre-line}.wrap.svelte-1xg7h5n{display:flex;flex-direction:column;justify-content:center;align-items:center;min-height:var(--size-60);color:var(--block-label-text-color);line-height:var(--line-md);height:100%;padding-top:var(--size-3);text-align:center;margin:auto var(--spacing-lg)}.or.svelte-1xg7h5n{color:var(--body-text-color-subdued);display:flex}.icon-wrap.svelte-1xg7h5n{width:30px;margin-bottom:var(--spacing-lg)}@media (--screen-md){.wrap.svelte-1xg7h5n{font-size:var(--text-lg)}}.hovered.svelte-1xg7h5n{color:var(--color-accent)}div.svelte-q32hvf{border-top:1px solid transparent;display:flex;max-height:100%;justify-content:center;align-items:center;gap:var(--spacing-sm);height:auto;align-items:flex-end;color:var(--block-label-text-color);flex-shrink:0}.show_border.svelte-q32hvf{border-top:1px solid var(--block-border-color);margin-top:var(--spacing-xxl);box-shadow:var(--shadow-drop)}.source-selection.svelte-15ls1gu{display:flex;align-items:center;justify-content:center;border-top:1px solid var(--border-color-primary);width:100%;margin-left:auto;margin-right:auto;height:var(--size-10)}.icon.svelte-15ls1gu{width:22px;height:22px;margin:var(--spacing-lg) var(--spacing-xs);padding:var(--spacing-xs);color:var(--neutral-400);border-radius:var(--radius-md)}.selected.svelte-15ls1gu{color:var(--color-accent)}.icon.svelte-15ls1gu:hover,.icon.svelte-15ls1gu:focus{color:var(--color-accent)}.icon-button-wrapper.svelte-109se4{display:flex;flex-direction:row;align-items:center;justify-content:center;z-index:var(--layer-3);gap:var(--spacing-sm);box-shadow:var(--shadow-drop);border:1px solid var(--border-color-primary);background:var(--block-background-fill);padding:var(--spacing-xxs)}.icon-button-wrapper.hide-top-corner.svelte-109se4{border-top:none;border-right:none;border-radius:var(--block-label-right-radius)}.icon-button-wrapper.display-top-corner.svelte-109se4{border-radius:var(--radius-sm) 0 0 var(--radius-sm);top:var(--spacing-sm);right:-1px}.icon-button-wrapper.svelte-109se4:not(.top-panel){border:1px solid var(--border-color-primary);border-radius:var(--radius-sm)}.top-panel.svelte-109se4{position:absolute;top:var(--block-label-margin);right:var(--block-label-margin);margin:0}.icon-button-wrapper.svelte-109se4 button{margin:var(--spacing-xxs);border-radius:var(--radius-xs);position:relative}.icon-button-wrapper.svelte-109se4 a.download-link:not(:last-child),.icon-button-wrapper.svelte-109se4 button:not(:last-child){margin-right:var(--spacing-xxs)}.icon-button-wrapper.svelte-109se4 a.download-link:not(:last-child):not(.no-border *):after,.icon-button-wrapper.svelte-109se4 button:not(:last-child):not(.no-border *):after{content:"";position:absolute;right:-4.5px;top:15%;height:70%;width:1px;background-color:var(--border-color-primary)}.icon-button-wrapper.svelte-109se4>*{height:100%}svg.svelte-43sxxs.svelte-43sxxs{width:var(--size-20);height:var(--size-20)}svg.svelte-43sxxs path.svelte-43sxxs{fill:var(--loader-color)}div.svelte-43sxxs.svelte-43sxxs{z-index:var(--layer-2)}.margin.svelte-43sxxs.svelte-43sxxs{margin:var(--size-4)}.wrap.svelte-17v219f.svelte-17v219f{display:flex;flex-direction:column;justify-content:center;align-items:center;z-index:var(--layer-2);transition:opacity .1s ease-in-out;border-radius:var(--block-radius);background:var(--block-background-fill);padding:0 var(--size-6);max-height:var(--size-screen-h);overflow:hidden}.wrap.center.svelte-17v219f.svelte-17v219f{top:0;right:0;left:0}.wrap.default.svelte-17v219f.svelte-17v219f{top:0;right:0;bottom:0;left:0}.hide.svelte-17v219f.svelte-17v219f{opacity:0;pointer-events:none}.generating.svelte-17v219f.svelte-17v219f{animation:svelte-17v219f-pulseStart 1s cubic-bezier(.4,0,.6,1),svelte-17v219f-pulse 2s cubic-bezier(.4,0,.6,1) 1s infinite;border:2px solid var(--color-accent);background:transparent;z-index:var(--layer-1);pointer-events:none}.translucent.svelte-17v219f.svelte-17v219f{background:none}@keyframes svelte-17v219f-pulseStart{0%{opacity:0}to{opacity:1}}@keyframes svelte-17v219f-pulse{0%,to{opacity:1}50%{opacity:.5}}.loading.svelte-17v219f.svelte-17v219f{z-index:var(--layer-2);color:var(--body-text-color)}.eta-bar.svelte-17v219f.svelte-17v219f{position:absolute;top:0;right:0;bottom:0;left:0;transform-origin:left;opacity:.8;z-index:var(--layer-1);transition:10ms;background:var(--background-fill-secondary)}.progress-bar-wrap.svelte-17v219f.svelte-17v219f{border:1px solid var(--border-color-primary);background:var(--background-fill-primary);width:55.5%;height:var(--size-4)}.progress-bar.svelte-17v219f.svelte-17v219f{transform-origin:left;background-color:var(--loader-color);width:var(--size-full);height:var(--size-full)}.progress-level.svelte-17v219f.svelte-17v219f{display:flex;flex-direction:column;align-items:center;gap:1;z-index:var(--layer-2);width:var(--size-full)}.progress-level-inner.svelte-17v219f.svelte-17v219f{margin:var(--size-2) auto;color:var(--body-text-color);font-size:var(--text-sm);font-family:var(--font-mono)}.meta-text.svelte-17v219f.svelte-17v219f{position:absolute;bottom:0;right:0;z-index:var(--layer-2);padding:var(--size-1) var(--size-2);font-size:var(--text-sm);font-family:var(--font-mono)}.meta-text-center.svelte-17v219f.svelte-17v219f{display:flex;position:absolute;top:0;right:0;justify-content:center;align-items:center;transform:translateY(var(--size-6));z-index:var(--layer-2);padding:var(--size-1) var(--size-2);font-size:var(--text-sm);font-family:var(--font-mono);text-align:center}.error.svelte-17v219f.svelte-17v219f{box-shadow:var(--shadow-drop);border:solid 1px var(--error-border-color);border-radius:var(--radius-full);background:var(--error-background-fill);padding-right:var(--size-4);padding-left:var(--size-4);color:var(--error-text-color);font-weight:var(--weight-semibold);font-size:var(--text-lg);line-height:var(--line-lg);font-family:var(--font)}.minimal.svelte-17v219f.svelte-17v219f{pointer-events:none}.minimal.svelte-17v219f .progress-text.svelte-17v219f{background:var(--block-background-fill)}.border.svelte-17v219f.svelte-17v219f{border:1px solid var(--border-color-primary)}.clear-status.svelte-17v219f.svelte-17v219f{position:absolute;display:flex;top:var(--size-2);right:var(--size-2);justify-content:flex-end;gap:var(--spacing-sm);z-index:var(--layer-1)}.toast-body.svelte-1pgj5gs{display:flex;position:relative;right:0;left:0;align-items:center;margin:var(--size-6) var(--size-4);margin:auto;border-radius:var(--container-radius);overflow:hidden;pointer-events:auto}.toast-body.error.svelte-1pgj5gs{border:1px solid var(--color-red-700);background:var(--color-red-50)}.dark .toast-body.error.svelte-1pgj5gs{border:1px solid var(--color-red-500);background-color:var(--color-grey-950)}.toast-body.warning.svelte-1pgj5gs{border:1px solid var(--color-yellow-700);background:var(--color-yellow-50)}.dark .toast-body.warning.svelte-1pgj5gs{border:1px solid var(--color-yellow-500);background-color:var(--color-grey-950)}.toast-body.info.svelte-1pgj5gs{border:1px solid var(--color-grey-700);background:var (--color-grey-50)}.dark .toast-body.info.svelte-1pgj5gs{border:1px solid var(--color-grey-500);background-color:var(--color-grey-950)}.toast-body.success.svelte-1pgj5gs{border:1px solid var(--color-green-700);background:var(--color-green-50)}.dark .toast-body.success.svelte-1pgj5gs{border:1px solid var(--color-green-500);background-color:var(--color-grey-950)}.toast-title.svelte-1pgj5gs{display:flex;align-items:center;font-weight:var(--weight-bold);font-size:var(--text-lg);line-height:var(--line-sm)}.toast-title.error.svelte-1pgj5gs{color:var(--color-red-700)}.dark .toast-title.error.svelte-1pgj5gs{color:var(--color-red-50)}.toast-title.warning.svelte-1pgj5gs{color:var(--color-yellow-700)}.dark .toast-title.warning.svelte-1pgj5gs{color:var(--color-yellow-50)}.toast-title.info.svelte-1pgj5gs{color:var(--color-grey-700)}.dark .toast-title.info.svelte-1pgj5gs{color:var(--color-grey-50)}.toast-title.success.svelte-1pgj5gs{color:var(--color-green-700)}.dark .toast-title.success.svelte-1pgj5gs{color:var(--color-green-50)}.toast-close.svelte-1pgj5gs{margin:0 var(--size-3);border-radius:var(--size-3);padding:0px var(--size-1-5);font-size:var(--size-5);line-height:var(--size-5)}.toast-close.error.svelte-1pgj5gs{color:var(--color-red-700)}.dark .toast-close.error.svelte-1pgj5gs{color:var(--color-red-500)}.toast-close.warning.svelte-1pgj5gs{color:var(--color-yellow-700)}.dark .toast-close.warning.svelte-1pgj5gs{color:var(--color-yellow-500)}.toast-close.info.svelte-1pgj5gs{color:var(--color-grey-700)}.dark .toast-close.info.svelte-1pgj5gs{color:var(--color-grey-500)}.toast-close.success.svelte-1pgj5gs{color:var(--color-green-700)}.dark .toast-close.success.svelte-1pgj5gs{color:var(--color-green-500)}.toast-text.svelte-1pgj5gs{font-size:var(--text-lg);word-wrap:break-word;overflow-wrap:break-word;word-break:break-word}.toast-text.error.svelte-1pgj5gs{color:var(--color-red-700)}.dark .toast-text.error.svelte-1pgj5gs{color:var(--color-red-50)}.toast-text.warning.svelte-1pgj5gs{color:var(--color-yellow-700)}.dark .toast-text.warning.svelte-1pgj5gs{color:var(--color-yellow-50)}.toast-text.info.svelte-1pgj5gs{color:var(--color-grey-700)}.dark .toast-text.info.svelte-1pgj5gs{color:var(--color-grey-50)}.toast-text.success.svelte-1pgj5gs{color:var(--color-green-700)}.dark .toast-text.success.svelte-1pgj5gs{color:var(--color-green-50)}.toast-details.svelte-1pgj5gs{margin:var(--size-3) var(--size-3) var(--size-3) 0;width:100%}.toast-icon.svelte-1pgj5gs{display:flex;position:absolute;position:relative;flex-shrink:0;justify-content:center;align-items:center;margin:var(--size-2);border-radius:var(--radius-full);padding:var(--size-1);padding-left:calc(var(--size-1) - 1px);width:35px;height:35px}.toast-icon.error.svelte-1pgj5gs{color:var(--color-red-700)}.dark .toast-icon.error.svelte-1pgj5gs{color:var(--color-red-500)}.toast-icon.warning.svelte-1pgj5gs{color:var(--color-yellow-700)}.dark .toast-icon.warning.svelte-1pgj5gs{color:var(--color-yellow-500)}.toast-icon.info.svelte-1pgj5gs{color:var(--color-grey-700)}.dark .toast-icon.info.svelte-1pgj5gs{color:var(--color-grey-500)}.toast-icon.success.svelte-1pgj5gs{color:var(--color-green-700)}.dark .toast-icon.success.svelte-1pgj5gs{color:var(--color-green-500)}@keyframes svelte-1pgj5gs-countdown{0%{transform:scaleX(1)}to{transform:scaleX(0)}}.timer.svelte-1pgj5gs{position:absolute;bottom:0;left:0;transform-origin:0 0;animation:svelte-1pgj5gs-countdown 10s linear forwards;width:100%;height:var(--size-1)}.timer.error.svelte-1pgj5gs{background:var(--color-red-700)}.dark .timer.error.svelte-1pgj5gs{background:var(--color-red-500)}.timer.warning.svelte-1pgj5gs{background:var(--color-yellow-700)}.dark .timer.warning.svelte-1pgj5gs{background:var(--color-yellow-500)}.timer.info.svelte-1pgj5gs{background:var(--color-grey-700)}.dark .timer.info.svelte-1pgj5gs{background:var(--color-grey-500)}.timer.success.svelte-1pgj5gs{background:var(--color-green-700)}.dark .timer.success.svelte-1pgj5gs{background:var(--color-green-500)}.hidden.svelte-1pgj5gs{display:none}.toast-text.svelte-1pgj5gs a{text-decoration:underline}.toast-wrap.svelte-gatr8h{display:flex;position:fixed;top:var(--size-4);right:var(--size-4);flex-direction:column;align-items:end;gap:var(--size-2);z-index:var(--layer-top);width:calc(100% - var(--size-8))}@media (--screen-sm){.toast-wrap.svelte-gatr8h{width:calc(var(--size-96) + var(--size-10))}}.streaming-bar.svelte-ga0jj6{position:absolute;bottom:0;left:0;right:0;height:4px;background-color:var(--primary-600);animation:svelte-ga0jj6-countdown linear forwards;z-index:1}@keyframes svelte-ga0jj6-countdown{0%{transform:translate(0)}to{transform:translate(-100%)}}.wrap.svelte-14nlqs6.svelte-14nlqs6{position:relative;width:100%;height:100%;z-index:var(--layer-1);overflow:hidden}.icon-wrap.svelte-14nlqs6.svelte-14nlqs6{display:flex;position:absolute;top:50%;transform:translate(-50%,-50%);left:50%;width:40px;transition:.2s;color:var(--body-text-color);height:30px;border-radius:5px;background-color:var(--color-accent);align-items:center;justify-content:center;z-index:var(--layer-3);box-shadow:0 0 5px 2px #0000004d;font-size:12px;pointer-events:auto}.icon.left.svelte-14nlqs6.svelte-14nlqs6{transform:rotate(135deg);text-shadow:-1px -1px 1px rgba(0,0,0,.1)}.icon.right.svelte-14nlqs6.svelte-14nlqs6{transform:rotate(-45deg);text-shadow:-1px -1px 1px rgba(0,0,0,.1)}.icon.center.svelte-14nlqs6.svelte-14nlqs6{display:block;width:1px;height:100%;background-color:var(--color);opacity:.5}.outer.svelte-14nlqs6.svelte-14nlqs6{width:40px;height:100%;position:absolute;cursor:grab;top:0;left:-20px;pointer-events:auto;z-index:1000}.grab.svelte-14nlqs6.svelte-14nlqs6{cursor:grabbing}.inner.svelte-14nlqs6.svelte-14nlqs6{width:1px;height:100%;background:var(--color);position:absolute;left:calc((100% - 1px)/2)}.disabled.svelte-14nlqs6.svelte-14nlqs6{cursor:not-allowed;opacity:.5}.disabled.svelte-14nlqs6 .inner.svelte-14nlqs6{box-shadow:none}.content.svelte-14nlqs6.svelte-14nlqs6{width:100%;height:100%;display:flex;justify-content:center;align-items:center}.unstyled-link.svelte-151nsdd{all:unset;cursor:pointer}.overlay.svelte-1p2o6nz{position:absolute;background-color:#0006;width:100%;height:100%}.hidden.svelte-1p2o6nz{display:none}.load-wrap.svelte-1p2o6nz{display:flex;justify-content:center;align-items:center;height:100%}.loader.svelte-1p2o6nz{display:flex;position:relative;background-color:var(--border-color-accent-subdued);animation:svelte-1p2o6nz-shadowPulse 2s linear infinite;box-shadow:-24px 0 var(--border-color-accent-subdued),24px 0 var(--border-color-accent-subdued);margin:var(--spacing-md);border-radius:50%;width:10px;height:10px;scale:.5}@keyframes svelte-1p2o6nz-shadowPulse{33%{box-shadow:-24px 0 var(--border-color-accent-subdued),24px 0 #fff;background:#fff}66%{box-shadow:-24px 0 #fff,24px 0 #fff;background:var(--border-color-accent-subdued)}to{box-shadow:-24px 0 #fff,24px 0 var(--border-color-accent-subdued);background:#fff}}.load-wrap.svelte-kn3uji{display:flex;justify-content:center;align-items:center;height:100%}.loader.svelte-kn3uji{display:flex;position:relative;background-color:var(--border-color-accent-subdued);animation:svelte-kn3uji-shadowPulse 2s linear infinite;box-shadow:-24px 0 var(--border-color-accent-subdued),24px 0 var(--border-color-accent-subdued);margin:var(--spacing-md);border-radius:50%;width:10px;height:10px;scale:.5}@keyframes svelte-kn3uji-shadowPulse{33%{box-shadow:-24px 0 var(--border-color-accent-subdued),24px 0 #fff;background:#fff}66%{box-shadow:-24px 0 #fff,24px 0 #fff;background:var(--border-color-accent-subdued)}to{box-shadow:-24px 0 #fff,24px 0 var(--border-color-accent-subdued);background:#fff}}.container.svelte-kn3uji{display:flex;flex-direction:column;align-items:center;justify-content:center;margin:var(--spacing-lg) var(--spacing-lg) 0 var(--spacing-lg)}#timeline.svelte-kn3uji{display:flex;height:var(--size-10);flex:1;position:relative}img.svelte-kn3uji{flex:1 1 auto;min-width:0;object-fit:cover;height:var(--size-12);border:1px solid var(--block-border-color);-webkit-user-select:none;user-select:none;z-index:1}.handle.svelte-kn3uji{width:3px;background-color:var(--color-accent);cursor:ew-resize;height:var(--size-12);z-index:3;position:absolute}.opaque-layer.svelte-kn3uji{background-color:#e6672840;border:1px solid var(--color-accent);height:var(--size-12);position:absolute;z-index:2}.wrap.svelte-cr2edf.svelte-cr2edf{overflow-y:auto;transition:opacity .5s ease-in-out;background:var(--block-background-fill);position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:var(--size-40);width:var(--size-full)}.wrap.svelte-cr2edf.svelte-cr2edf:after{content:"";position:absolute;top:0;left:0;width:var(--upload-progress-width);height:100%;transition:all .5s ease-in-out;z-index:1}.uploading.svelte-cr2edf.svelte-cr2edf{font-size:var(--text-lg);font-family:var(--font);z-index:2}.file-name.svelte-cr2edf.svelte-cr2edf{margin:var(--spacing-md);font-size:var(--text-lg);color:var(--body-text-color-subdued)}.file.svelte-cr2edf.svelte-cr2edf{font-size:var(--text-md);z-index:2;display:flex;align-items:center}.file.svelte-cr2edf progress.svelte-cr2edf{display:inline;height:var(--size-1);width:100%;transition:all .5s ease-in-out;color:var(--color-accent);border:none}.file.svelte-cr2edf progress[value].svelte-cr2edf::-webkit-progress-value{background-color:var(--color-accent);border-radius:20px}.file.svelte-cr2edf progress[value].svelte-cr2edf::-webkit-progress-bar{background-color:var(--border-color-accent);border-radius:20px}.progress-bar.svelte-cr2edf.svelte-cr2edf{width:14px;height:14px;border-radius:50%;background:radial-gradient(closest-side,var(--block-background-fill) 64%,transparent 53% 100%),conic-gradient(var(--color-accent) var(--upload-progress-width),var(--border-color-accent) 0);transition:all .5s ease-in-out}button.svelte-1o7nwih{cursor:pointer;width:var(--size-full)}.center.svelte-1o7nwih{display:flex;justify-content:center}.flex.svelte-1o7nwih{display:flex;flex-direction:column;justify-content:center;align-items:center}.hidden.svelte-1o7nwih{display:none;position:absolute;flex-grow:0}.hidden.svelte-1o7nwih svg{display:none}.disable_click.svelte-1o7nwih{cursor:default}.icon-mode.svelte-1o7nwih{position:absolute!important;width:var(--size-4);height:var(--size-4);padding:0;min-height:0;border-radius:var(--radius-circle)}.icon-mode.svelte-1o7nwih svg{width:var(--size-4);height:var(--size-4)}.container.svelte-uu53fr.svelte-uu53fr{width:100%}time.svelte-uu53fr.svelte-uu53fr{color:var(--color-accent);font-weight:700;padding-left:var(--spacing-xs)}.timeline-wrapper.svelte-uu53fr.svelte-uu53fr{display:flex;align-items:center;justify-content:center;width:100%}.text-button.svelte-uu53fr.svelte-uu53fr{border:1px solid var(--neutral-400);border-radius:var(--radius-sm);font-weight:300;font-size:var(--size-3);text-align:center;color:var(--neutral-400);height:var(--size-5);font-weight:700;padding:0 5px;margin-left:5px}.text-button.svelte-uu53fr.svelte-uu53fr:hover,.text-button.svelte-uu53fr.svelte-uu53fr:focus{color:var(--color-accent);border-color:var(--color-accent)}.controls.svelte-uu53fr.svelte-uu53fr{display:flex;justify-content:space-between;align-items:center;margin:var(--spacing-lg);overflow:hidden}.edit-buttons.svelte-uu53fr.svelte-uu53fr{display:flex;gap:var(--spacing-sm)}@media (max-width: 320px){.controls.svelte-uu53fr.svelte-uu53fr{flex-direction:column;align-items:flex-start}.edit-buttons.svelte-uu53fr.svelte-uu53fr{margin-top:var(--spacing-sm)}.controls.svelte-uu53fr .svelte-uu53fr{margin:var(--spacing-sm)}.controls.svelte-uu53fr .text-button.svelte-uu53fr{margin-left:0}}.container.svelte-uu53fr.svelte-uu53fr{display:flex;flex-direction:column}.hidden.svelte-uu53fr.svelte-uu53fr{display:none}span.svelte-bmp2ea.svelte-bmp2ea{text-shadow:0 0 8px rgba(0,0,0,.5)}input[type=range].svelte-bmp2ea.svelte-bmp2ea{margin-right:var(--size-3);width:var(--size-full);height:var(--size-2)}.mirror.svelte-bmp2ea.svelte-bmp2ea{transform:scaleX(-1)}.mirror-wrap.svelte-bmp2ea.svelte-bmp2ea{position:relative;height:100%;width:100%}.controls.svelte-bmp2ea.svelte-bmp2ea{position:absolute;bottom:0;opacity:0;transition:.5s;margin:var(--size-2);border-radius:var(--radius-md);background:var(--color-grey-800);padding:var(--size-2) var(--size-1);width:calc(100% - var(--size-2) * 2)}.wrap.svelte-bmp2ea:hover .controls.svelte-bmp2ea{opacity:1}.inner.svelte-bmp2ea.svelte-bmp2ea{display:flex;justify-content:space-between;align-items:center;padding-right:var(--size-2);padding-left:var(--size-2);width:var(--size-full);height:var(--size-full)}.icon.svelte-bmp2ea.svelte-bmp2ea{display:flex;justify-content:center;cursor:pointer;width:var(--size-6);color:#fff}.time.svelte-bmp2ea.svelte-bmp2ea{flex-shrink:0;margin-right:var(--size-3);margin-left:var(--size-3);color:#fff;font-size:var(--text-sm);font-family:var(--font-mono)}.wrap.svelte-bmp2ea.svelte-bmp2ea{position:relative;background-color:var(--background-fill-secondary);height:var(--size-full);width:var(--size-full);border-radius:var(--radius-xl)}.wrap.svelte-bmp2ea video{height:var(--size-full);width:var(--size-full);object-fit:contain}.loading-spinner.svelte-bmp2ea.svelte-bmp2ea{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;background:#000000b3;padding:1rem;border-radius:.5rem}.video-container.svelte-40zsic{height:100%;width:100%;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;z-index:1}.main-wrapper.svelte-40zsic{-webkit-user-select:none;user-select:none;height:100%;width:100%;position:relative;display:flex;align-items:center;justify-content:center;z-index:2}.player-wrapper.svelte-40zsic{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center;z-index:3;pointer-events:none}.player-wrapper.fixed.svelte-40zsic{background:var(--block-background-fill);z-index:4}.player-wrapper.svelte-40zsic video{width:100%;height:100%;object-fit:contain;z-index:5;pointer-events:none}.main-wrapper>.wrap{position:absolute;top:0;left:0;z-index:10;cursor:default}.icon-button-wrapper.svelte-40zsic{z-index:1001;pointer-events:auto;position:absolute;top:10px;right:10px}img.svelte-kxeri3{object-fit:cover}.image-container.svelte-x2tujq.svelte-x2tujq{height:100%;position:relative;min-width:var(--size-20)}.image-container.svelte-x2tujq button.svelte-x2tujq{width:var(--size-full);height:var(--size-full);border-radius:var(--radius-lg);display:flex;align-items:center;justify-content:center}.image-frame.svelte-x2tujq img{width:var(--size-full);height:var(--size-full);object-fit:scale-down}.selectable.svelte-x2tujq.svelte-x2tujq{cursor:crosshair}.fullscreen-controls svg{position:relative;top:0}.image-container:fullscreen{background-color:#000;display:flex;justify-content:center;align-items:center}.image-container:fullscreen img{max-width:90vw;max-height:90vh;object-fit:scale-down}.image-frame.svelte-x2tujq.svelte-x2tujq{width:auto;height:100%;display:flex;align-items:center;justify-content:center}button.svelte-fjcd9c{cursor:pointer;width:var(--size-full)}.wrap.svelte-fjcd9c{display:flex;flex-direction:column;justify-content:center;align-items:center;min-height:var(--size-60);color:var(--block-label-text-color);height:100%;padding-top:var(--size-3)}.icon-wrap.svelte-fjcd9c{width:30px;margin-bottom:var(--spacing-lg)}@media (--screen-md){.wrap.svelte-fjcd9c{font-size:var(--text-lg)}}.wrap.svelte-1tdan5a.svelte-1tdan5a{position:relative;width:var(--size-full);height:var(--size-full)}.hide.svelte-1tdan5a.svelte-1tdan5a{display:none}video.svelte-1tdan5a.svelte-1tdan5a{width:var(--size-full);height:var(--size-full);object-fit:contain}.button-wrap.svelte-1tdan5a.svelte-1tdan5a{position:absolute;background-color:var(--block-background-fill);border:1px solid var(--border-color-primary);padding:var(--size-1-5);display:flex;bottom:var(--size-2);left:50%;transform:translate(-50%);box-shadow:var(--shadow-drop-lg);border-radius:var(--radius-xl);line-height:var(--size-3);color:var(--button-secondary-text-color)}.icon-with-text.svelte-1tdan5a.svelte-1tdan5a{width:var(--size-20);align-items:center;margin:0 var(--spacing-xl);display:flex;justify-content:space-evenly}@media (--screen-md){button.svelte-1tdan5a.svelte-1tdan5a{bottom:var(--size-4)}}@media (--screen-xl){button.svelte-1tdan5a.svelte-1tdan5a{bottom:var(--size-8)}}.icon.svelte-1tdan5a.svelte-1tdan5a{width:18px;height:18px;display:flex;justify-content:space-between;align-items:center}.color-primary.svelte-1tdan5a.svelte-1tdan5a{fill:var(--primary-600);stroke:var(--primary-600);color:var(--primary-600)}.flip.svelte-1tdan5a.svelte-1tdan5a{transform:scaleX(-1)}.select-wrap.svelte-1tdan5a.svelte-1tdan5a{-webkit-appearance:none;-moz-appearance:none;appearance:none;color:var(--button-secondary-text-color);background-color:transparent;width:95%;font-size:var(--text-md);position:absolute;bottom:var(--size-2);background-color:var(--block-background-fill);box-shadow:var(--shadow-drop-lg);border-radius:var(--radius-xl);z-index:var(--layer-top);border:1px solid var(--border-color-primary);text-align:left;line-height:var(--size-4);white-space:nowrap;text-overflow:ellipsis;left:50%;transform:translate(-50%);max-width:var(--size-52)}.select-wrap.svelte-1tdan5a>option.svelte-1tdan5a{padding:.25rem .5rem;border-bottom:1px solid var(--border-color-accent);padding-right:var(--size-8);text-overflow:ellipsis;overflow:hidden}.select-wrap.svelte-1tdan5a>option.svelte-1tdan5a:hover{background-color:var(--color-accent)}.select-wrap.svelte-1tdan5a>option.svelte-1tdan5a:last-child{border:none}.inset-icon.svelte-1tdan5a.svelte-1tdan5a{position:absolute;top:5px;right:-6.5px;width:var(--size-10);height:var(--size-5);opacity:.8}@media (--screen-md){.wrap.svelte-1tdan5a.svelte-1tdan5a{font-size:var(--text-lg)}}.image-frame.svelte-1562gr6 img{width:var(--size-full);height:var(--size-full);object-fit:scale-down}.upload-container.svelte-1562gr6{display:flex;align-items:center;justify-content:center;height:100%;flex-shrink:1;max-height:100%}.reduced-height.svelte-1562gr6{height:calc(100% - var(--size-10))}.image-container.svelte-1562gr6{display:flex;height:100%;flex-direction:column;justify-content:center;align-items:center;max-height:100%}.selectable.svelte-1562gr6{cursor:crosshair}.image-frame.svelte-1562gr6{object-fit:cover;width:100%;height:100%}.container.svelte-1sgcyba img{width:100%;height:100%}.container.selected.svelte-1sgcyba{border-color:var(--border-color-accent)}.border.table.svelte-1sgcyba{border:2px solid var(--border-color-primary)}.container.table.svelte-1sgcyba{margin:0 auto;border-radius:var(--radius-lg);overflow:hidden;width:var(--size-20);height:var(--size-20);object-fit:cover}.container.gallery.svelte-1sgcyba{width:var(--size-20);max-width:var(--size-20);object-fit:cover}.file-name.svelte-1kyjvp4{padding:var(--size-6);font-size:var(--text-xxl);word-break:break-all}.file-size.svelte-1kyjvp4{padding:var(--size-2);font-size:var(--text-xl)}.upload-container.svelte-1kyjvp4{height:100%;width:100%}.video-container.svelte-1kyjvp4{display:flex;height:100%;flex-direction:column;justify-content:center;align-items:center}.container.svelte-9k2sta{display:flex;flex-direction:row;gap:var(--spacing-lg);width:100%;height:100%}.video-slot.svelte-9k2sta{flex:1;display:flex;justify-content:center;align-items:center;min-height:var(--size-60);border:1px solid var(--border-color-primary);border-radius:var(--radius-lg);overflow:hidden;position:relative}.video-slot .upload-text{color:var(--body-text-color-subdued);text-align:center}
src/backend/gradio_videoslider/videoslider.py CHANGED
@@ -2,7 +2,7 @@
2
  from __future__ import annotations
3
 
4
  from pathlib import Path
5
- from typing import Any, Callable, List, Tuple
6
 
7
  from gradio_client import handle_file
8
  from gradio_client.documentation import document
@@ -14,24 +14,24 @@ from gradio.events import Events
14
 
15
  class VideoSliderData(GradioRootModel):
16
  """
17
- Data model for the VideoSlider component. Represents the data structure
18
- sent between the frontend and backend, which is a tuple of two FileData objects.
19
  """
20
  root: Tuple[FileData | None, FileData | None]
21
 
22
- # Type alias for the value passed to or returned from a user's function.
23
- # It's a tuple of two file paths (string or Path object).
24
  VideoSliderValue = Tuple[str | Path | None, str | Path | None]
25
 
26
  @document()
27
  class VideoSlider(Component):
28
  """
29
  A custom Gradio component to display a side-by-side video comparison with a slider.
30
- It can be used as both an input (uploading two videos) and an output (displaying two videos).
31
  """
32
  # The data model used for communication with the frontend.
33
  data_model = VideoSliderData
34
- # The events that this component can trigger.
35
  EVENTS = [Events.change, Events.upload, Events.clear]
36
 
37
  def __init__(
@@ -52,7 +52,9 @@ class VideoSlider(Component):
52
  elem_classes: List[str] | str | None = None,
53
  position: int = 50,
54
  show_download_button: bool = True,
 
55
  show_fullscreen_button: bool = True,
 
56
  autoplay: bool = False,
57
  loop: bool = False,
58
  ):
@@ -60,23 +62,38 @@ class VideoSlider(Component):
60
  Initializes the VideoSlider component.
61
 
62
  Parameters:
63
- value: A tuple of two video file paths or URLs to display initially.
64
- height: The height of the component in pixels.
65
- width: The width of the component in pixels.
66
- label: The label for this component.
67
- position: The initial position of the slider, from 0 to 100.
68
- autoplay: If True, the videos will start playing automatically.
69
- loop: If True, the videos will loop when they finish.
70
- interactive: If False, the component will be in display-only mode.
 
 
 
 
 
 
 
 
 
 
 
 
71
  """
72
  self.height = height
73
  self.width = width
74
  self.position = position
75
  self.show_download_button = show_download_button
76
  self.show_fullscreen_button = show_fullscreen_button
 
 
77
  self.autoplay = autoplay
78
  self.loop = loop
79
- self.type = "filepath" # The component handles file paths.
 
80
 
81
  super().__init__(
82
  label=label,
@@ -94,8 +111,7 @@ class VideoSlider(Component):
94
 
95
  def preprocess(self, payload: VideoSliderData | None) -> VideoSliderValue | None:
96
  """
97
- Processes data from the frontend into a format usable by a Python function.
98
- It converts the FileData objects into a tuple of simple string file paths.
99
  """
100
  if payload is None or payload.root is None:
101
  return None
@@ -109,9 +125,8 @@ class VideoSlider(Component):
109
 
110
  def postprocess(self, value: VideoSliderValue | None) -> VideoSliderData | None:
111
  """
112
- Processes data returned from a Python function into a format for the frontend.
113
- It takes a tuple of file paths, makes them servable by Gradio, and returns
114
- a VideoSliderData object.
115
  """
116
  if value is None or (value[0] is None and value[1] is None):
117
  return None
@@ -120,7 +135,7 @@ class VideoSlider(Component):
120
 
121
  fd1 = None
122
  if video1_path:
123
- # Copies the file to a temp cache and returns a FileData object.
124
  new_path = processing_utils.move_resource_to_block_cache(video1_path, self)
125
  fd1 = FileData(path=str(new_path))
126
 
@@ -133,7 +148,7 @@ class VideoSlider(Component):
133
 
134
  def api_info(self) -> dict[str, Any]:
135
  """
136
- Provides API information for the component.
137
  """
138
  return {"type": "array", "items": {"type": "string", "description": "path to video file"}, "length": 2}
139
 
 
2
  from __future__ import annotations
3
 
4
  from pathlib import Path
5
+ from typing import Any, Callable, List, Literal, Tuple
6
 
7
  from gradio_client import handle_file
8
  from gradio_client.documentation import document
 
14
 
15
  class VideoSliderData(GradioRootModel):
16
  """
17
+ Pydantic model for the data structure sent between the frontend and backend.
18
+ It represents a tuple of two (optional) FileData objects.
19
  """
20
  root: Tuple[FileData | None, FileData | None]
21
 
22
+ # Type alias for the value that the user's Python function will receive or return.
23
+ # It is a tuple of two (optional) file paths.
24
  VideoSliderValue = Tuple[str | Path | None, str | Path | None]
25
 
26
  @document()
27
  class VideoSlider(Component):
28
  """
29
  A custom Gradio component to display a side-by-side video comparison with a slider.
30
+ Can be used as an input (for uploading two videos) or as an output (for displaying two videos).
31
  """
32
  # The data model used for communication with the frontend.
33
  data_model = VideoSliderData
34
+ # A list of events that this component supports.
35
  EVENTS = [Events.change, Events.upload, Events.clear]
36
 
37
  def __init__(
 
52
  elem_classes: List[str] | str | None = None,
53
  position: int = 50,
54
  show_download_button: bool = True,
55
+ show_mute_button: bool = True,
56
  show_fullscreen_button: bool = True,
57
+ video_mode: Literal["upload", "preview"] = "preview",
58
  autoplay: bool = False,
59
  loop: bool = False,
60
  ):
 
62
  Initializes the VideoSlider component.
63
 
64
  Parameters:
65
+ value: A tuple of two video file paths or URLs to display initially. Can also be a callable.
66
+ height: The height of the component container in pixels.
67
+ width: The width of the component container in pixels.
68
+ label: The label for this component that appears above it.
69
+ every: If `value` is a callable, run the function 'every' seconds while the client connection is open.
70
+ show_label: If False, the label is not displayed.
71
+ container: If False, the component will not be wrapped in a container.
72
+ scale: An integer that defines the component's relative size in a layout.
73
+ min_width: The minimum width of the component in pixels.
74
+ interactive: If True, the component is in input mode (upload). If False, it's in display-only mode.
75
+ visible: If False, the component is not rendered.
76
+ elem_id: An optional string that is assigned as the id of the component in the HTML.
77
+ elem_classes: An optional list of strings that are assigned as the classes of the component in the HTML.
78
+ position: The initial horizontal position of the slider, from 0 (left) to 100 (right).
79
+ show_download_button: If True, a download button is shown for the second video.
80
+ show_mute_button: If True, a mute/unmute button is shown.
81
+ show_fullscreen_button: If True, a fullscreen button is shown.
82
+ video_mode: The mode of the component, either "upload" or "preview".
83
+ autoplay: If True, videos will start playing automatically on load (muted).
84
+ loop: If True, videos will loop when they finish playing.
85
  """
86
  self.height = height
87
  self.width = width
88
  self.position = position
89
  self.show_download_button = show_download_button
90
  self.show_fullscreen_button = show_fullscreen_button
91
+ self.show_mute_button = show_mute_button
92
+ self.video_mode = video_mode
93
  self.autoplay = autoplay
94
  self.loop = loop
95
+ # The component's value is processed as file paths.
96
+ self.type = "filepath"
97
 
98
  super().__init__(
99
  label=label,
 
111
 
112
  def preprocess(self, payload: VideoSliderData | None) -> VideoSliderValue | None:
113
  """
114
+ Converts data from the frontend (as FileData) to a format usable by a Python function (a tuple of file paths).
 
115
  """
116
  if payload is None or payload.root is None:
117
  return None
 
125
 
126
  def postprocess(self, value: VideoSliderValue | None) -> VideoSliderData | None:
127
  """
128
+ Converts data from a Python function (a tuple of file paths) into a format for the frontend (FileData).
129
+ This involves making the local files servable by Gradio.
 
130
  """
131
  if value is None or (value[0] is None and value[1] is None):
132
  return None
 
135
 
136
  fd1 = None
137
  if video1_path:
138
+ # Copies the file to a temporary cache and creates a FileData object.
139
  new_path = processing_utils.move_resource_to_block_cache(video1_path, self)
140
  fd1 = FileData(path=str(new_path))
141
 
 
148
 
149
  def api_info(self) -> dict[str, Any]:
150
  """
151
+ Provides type information for the component's API documentation.
152
  """
153
  return {"type": "array", "items": {"type": "string", "description": "path to video file"}, "length": 2}
154
 
src/demo/app.py CHANGED
@@ -1,4 +1,3 @@
1
- # In demo/app.py
2
  import gradio as gr
3
  from gradio_videoslider import VideoSlider
4
  import os
@@ -8,8 +7,8 @@ import os
8
  # IMPORTANT: Replace the values below with the paths to YOUR video files.
9
  #
10
  # Option A: Relative Path (if the video is in the same folder as this app.py)
11
- # video_path_1 = "video_antes.mp4"
12
- # video_path_2 = "video_depois.mp4"
13
  #
14
  # Option B: Absolute Path (the full path to the file on your computer)
15
  # Example for Windows:
@@ -23,7 +22,7 @@ video_path_1 = "examples/SampleVideo 720x480.mp4"
23
  video_path_2 = "examples/SampleVideo 1280x720.mp4"
24
 
25
 
26
- # --- 2. FUNCTION FOR THE UPLOAD EXAMPLE (remains the same) ---
27
  def process_uploaded_videos(video_inputs):
28
  """This function handles the uploaded videos."""
29
  print("Received videos from upload:", video_inputs)
@@ -39,11 +38,13 @@ with gr.Blocks() as demo:
39
  # --- TAB 1: UPLOAD EXAMPLE ---
40
  with gr.TabItem("1. Compare via Upload"):
41
  gr.Markdown("## Upload two videos to compare them side-by-side.")
42
- video_slider_input = VideoSlider(label="Your Videos", height=400, width=700)
43
  video_slider_output = VideoSlider(
44
  label="Video comparision",
45
  interactive=False,
46
- autoplay=True,
 
 
47
  loop=True,
48
  height=400,
49
  width=700
@@ -64,13 +65,15 @@ with gr.Blocks() as demo:
64
  label="Video comparision",
65
  value=(video_path_1, video_path_2),
66
  interactive=False,
 
67
  autoplay=True,
 
68
  loop=True,
69
  height=400,
70
  width=700
71
  )
72
 
73
- # Optional: A check to give a helpful error message if files are not found.
74
  if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
75
  print("---")
76
  print(f"WARNING: Could not find one or both video files.")
@@ -80,4 +83,4 @@ if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
80
  print("---")
81
 
82
  if __name__ == '__main__':
83
- demo.launch()
 
 
1
  import gradio as gr
2
  from gradio_videoslider import VideoSlider
3
  import os
 
7
  # IMPORTANT: Replace the values below with the paths to YOUR video files.
8
  #
9
  # Option A: Relative Path (if the video is in the same folder as this app.py)
10
+ # video_path_1 = "video_before.mp4"
11
+ # video_path_2 = "video_after.mp4"
12
  #
13
  # Option B: Absolute Path (the full path to the file on your computer)
14
  # Example for Windows:
 
22
  video_path_2 = "examples/SampleVideo 1280x720.mp4"
23
 
24
 
25
+ # --- 2. FUNCTION FOR THE UPLOAD EXAMPLE ---
26
  def process_uploaded_videos(video_inputs):
27
  """This function handles the uploaded videos."""
28
  print("Received videos from upload:", video_inputs)
 
38
  # --- TAB 1: UPLOAD EXAMPLE ---
39
  with gr.TabItem("1. Compare via Upload"):
40
  gr.Markdown("## Upload two videos to compare them side-by-side.")
41
+ video_slider_input = VideoSlider(label="Your Videos", height=400, width=700, video_mode="upload")
42
  video_slider_output = VideoSlider(
43
  label="Video comparision",
44
  interactive=False,
45
+ autoplay=True,
46
+ video_mode="preview",
47
+ show_download_button=False,
48
  loop=True,
49
  height=400,
50
  width=700
 
65
  label="Video comparision",
66
  value=(video_path_1, video_path_2),
67
  interactive=False,
68
+ show_download_button=False,
69
  autoplay=True,
70
+ video_mode="preview",
71
  loop=True,
72
  height=400,
73
  width=700
74
  )
75
 
76
+ # A check to give a helpful error message if files are not found.
77
  if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
78
  print("---")
79
  print(f"WARNING: Could not find one or both video files.")
 
83
  print("---")
84
 
85
  if __name__ == '__main__':
86
+ demo.launch(debug=True)
src/demo/space.py CHANGED
@@ -3,7 +3,7 @@ import gradio as gr
3
  from app import demo as app
4
  import os
5
 
6
- _docs = {'VideoSlider': {'description': 'A custom Gradio component to display a side-by-side video comparison with a slider.\nIt can be used as both an input (uploading two videos) and an output (displaying two videos).', 'members': {'__init__': {'value': {'type': 'typing.Union[\n typing.Tuple[str | pathlib.Path, str | pathlib.Path],\n typing.Callable,\n NoneType,\n][\n typing.Tuple[str | pathlib.Path, str | pathlib.Path][\n str | pathlib.Path, str | pathlib.Path\n ],\n Callable,\n None,\n]', 'default': 'None', 'description': 'A tuple of two video file paths or URLs to display initially.'}, 'height': {'type': 'int | None', 'default': 'None', 'description': 'The height of the component in pixels.'}, 'width': {'type': 'int | None', 'default': 'None', 'description': 'The width of the component in pixels.'}, 'label': {'type': 'str | None', 'default': 'None', 'description': 'The label for this component.'}, 'every': {'type': 'float | None', 'default': 'None', 'description': None}, 'show_label': {'type': 'bool | None', 'default': 'None', 'description': None}, 'container': {'type': 'bool', 'default': 'True', 'description': None}, 'scale': {'type': 'int | None', 'default': 'None', 'description': None}, 'min_width': {'type': 'int', 'default': '160', 'description': None}, 'interactive': {'type': 'bool | None', 'default': 'None', 'description': 'If False, the component will be in display-only mode.'}, 'visible': {'type': 'bool', 'default': 'True', 'description': None}, 'elem_id': {'type': 'str | None', 'default': 'None', 'description': None}, 'elem_classes': {'type': 'typing.Union[typing.List[str], str, NoneType][\n typing.List[str][str], str, None\n]', 'default': 'None', 'description': None}, 'position': {'type': 'int', 'default': '50', 'description': 'The initial position of the slider, from 0 to 100.'}, 'show_download_button': {'type': 'bool', 'default': 'True', 'description': None}, 'show_fullscreen_button': {'type': 'bool', 'default': 'True', 'description': None}, 'autoplay': {'type': 'bool', 'default': 'False', 'description': 'If True, the videos will start playing automatically.'}, 'loop': {'type': 'bool', 'default': 'False', 'description': 'If True, the videos will loop when they finish.'}}, 'postprocess': {'value': {'type': 'typing.Optional[\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ]\n][\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ][str | pathlib.Path | None, str | pathlib.Path | None],\n None,\n]', 'description': None}}, 'preprocess': {'return': {'type': 'typing.Optional[\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ]\n][\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ][str | pathlib.Path | None, str | pathlib.Path | None],\n None,\n]', 'description': None}, 'value': None}}, 'events': {'change': {'type': None, 'default': None, 'description': 'Triggered when the value of the VideoSlider changes either because of user input (e.g. a user types in a textbox) OR because of a function update (e.g. an image receives a value from the output of an event trigger). See `.input()` for a listener that is only triggered by user input.'}, 'upload': {'type': None, 'default': None, 'description': 'This listener is triggered when the user uploads a file into the VideoSlider.'}, 'clear': {'type': None, 'default': None, 'description': 'This listener is triggered when the user clears the VideoSlider using the clear button for the component.'}}}, '__meta__': {'additional_interfaces': {}, 'user_fn_refs': {'VideoSlider': []}}}
7
 
8
  abs_path = os.path.join(os.path.dirname(__file__), "css.css")
9
 
@@ -21,7 +21,7 @@ with gr.Blocks(
21
  # `gradio_videoslider`
22
 
23
  <div style="display: flex; gap: 7px;">
24
- <img alt="Static Badge" src="https://img.shields.io/badge/version%20-%200.0.1%20-%20orange">
25
  </div>
26
 
27
  VideoSlider Component for Gradio
@@ -38,7 +38,6 @@ pip install gradio_videoslider
38
  ## Usage
39
 
40
  ```python
41
- # In demo/app.py
42
  import gradio as gr
43
  from gradio_videoslider import VideoSlider
44
  import os
@@ -48,8 +47,8 @@ import os
48
  # IMPORTANT: Replace the values below with the paths to YOUR video files.
49
  #
50
  # Option A: Relative Path (if the video is in the same folder as this app.py)
51
- # video_path_1 = "video_antes.mp4"
52
- # video_path_2 = "video_depois.mp4"
53
  #
54
  # Option B: Absolute Path (the full path to the file on your computer)
55
  # Example for Windows:
@@ -63,7 +62,7 @@ video_path_1 = "examples/SampleVideo 720x480.mp4"
63
  video_path_2 = "examples/SampleVideo 1280x720.mp4"
64
 
65
 
66
- # --- 2. FUNCTION FOR THE UPLOAD EXAMPLE (remains the same) ---
67
  def process_uploaded_videos(video_inputs):
68
  \"\"\"This function handles the uploaded videos.\"\"\"
69
  print("Received videos from upload:", video_inputs)
@@ -79,11 +78,13 @@ with gr.Blocks() as demo:
79
  # --- TAB 1: UPLOAD EXAMPLE ---
80
  with gr.TabItem("1. Compare via Upload"):
81
  gr.Markdown("## Upload two videos to compare them side-by-side.")
82
- video_slider_input = VideoSlider(label="Your Videos", height=400, width=700)
83
  video_slider_output = VideoSlider(
84
  label="Video comparision",
85
  interactive=False,
86
- autoplay=True,
 
 
87
  loop=True,
88
  height=400,
89
  width=700
@@ -104,13 +105,15 @@ with gr.Blocks() as demo:
104
  label="Video comparision",
105
  value=(video_path_1, video_path_2),
106
  interactive=False,
 
107
  autoplay=True,
 
108
  loop=True,
109
  height=400,
110
  width=700
111
  )
112
 
113
- # Optional: A check to give a helpful error message if files are not found.
114
  if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
115
  print("---")
116
  print(f"WARNING: Could not find one or both video files.")
@@ -120,7 +123,7 @@ if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
120
  print("---")
121
 
122
  if __name__ == '__main__':
123
- demo.launch()
124
 
125
  ```
126
  """, elem_classes=["md-custom"], header_links=True)
 
3
  from app import demo as app
4
  import os
5
 
6
+ _docs = {'VideoSlider': {'description': 'A custom Gradio component to display a side-by-side video comparison with a slider.\nCan be used as an input (for uploading two videos) or as an output (for displaying two videos).', 'members': {'__init__': {'value': {'type': 'typing.Union[\n typing.Tuple[str | pathlib.Path, str | pathlib.Path],\n typing.Callable,\n NoneType,\n][\n typing.Tuple[str | pathlib.Path, str | pathlib.Path][\n str | pathlib.Path, str | pathlib.Path\n ],\n Callable,\n None,\n]', 'default': 'None', 'description': 'A tuple of two video file paths or URLs to display initially. Can also be a callable.'}, 'height': {'type': 'int | None', 'default': 'None', 'description': 'The height of the component container in pixels.'}, 'width': {'type': 'int | None', 'default': 'None', 'description': 'The width of the component container in pixels.'}, 'label': {'type': 'str | None', 'default': 'None', 'description': 'The label for this component that appears above it.'}, 'every': {'type': 'float | None', 'default': 'None', 'description': "If `value` is a callable, run the function 'every' seconds while the client connection is open."}, 'show_label': {'type': 'bool | None', 'default': 'None', 'description': 'If False, the label is not displayed.'}, 'container': {'type': 'bool', 'default': 'True', 'description': 'If False, the component will not be wrapped in a container.'}, 'scale': {'type': 'int | None', 'default': 'None', 'description': "An integer that defines the component's relative size in a layout."}, 'min_width': {'type': 'int', 'default': '160', 'description': 'The minimum width of the component in pixels.'}, 'interactive': {'type': 'bool | None', 'default': 'None', 'description': "If True, the component is in input mode (upload). If False, it's in display-only mode."}, 'visible': {'type': 'bool', 'default': 'True', 'description': 'If False, the component is not rendered.'}, 'elem_id': {'type': 'str | None', 'default': 'None', 'description': 'An optional string that is assigned as the id of the component in the HTML.'}, 'elem_classes': {'type': 'typing.Union[typing.List[str], str, NoneType][\n typing.List[str][str], str, None\n]', 'default': 'None', 'description': 'An optional list of strings that are assigned as the classes of the component in the HTML.'}, 'position': {'type': 'int', 'default': '50', 'description': 'The initial horizontal position of the slider, from 0 (left) to 100 (right).'}, 'show_download_button': {'type': 'bool', 'default': 'True', 'description': 'If True, a download button is shown for the second video.'}, 'show_mute_button': {'type': 'bool', 'default': 'True', 'description': 'If True, a mute/unmute button is shown.'}, 'show_fullscreen_button': {'type': 'bool', 'default': 'True', 'description': 'If True, a fullscreen button is shown.'}, 'video_mode': {'type': '"upload" | "preview"', 'default': '"preview"', 'description': 'The mode of the component, either "upload" or "preview".'}, 'autoplay': {'type': 'bool', 'default': 'False', 'description': 'If True, videos will start playing automatically on load (muted).'}, 'loop': {'type': 'bool', 'default': 'False', 'description': 'If True, videos will loop when they finish playing.'}}, 'postprocess': {'value': {'type': 'typing.Optional[\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ]\n][\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ][str | pathlib.Path | None, str | pathlib.Path | None],\n None,\n]', 'description': None}}, 'preprocess': {'return': {'type': 'typing.Optional[\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ]\n][\n typing.Tuple[\n str | pathlib.Path | None, str | pathlib.Path | None\n ][str | pathlib.Path | None, str | pathlib.Path | None],\n None,\n]', 'description': None}, 'value': None}}, 'events': {'change': {'type': None, 'default': None, 'description': 'Triggered when the value of the VideoSlider changes either because of user input (e.g. a user types in a textbox) OR because of a function update (e.g. an image receives a value from the output of an event trigger). See `.input()` for a listener that is only triggered by user input.'}, 'upload': {'type': None, 'default': None, 'description': 'This listener is triggered when the user uploads a file into the VideoSlider.'}, 'clear': {'type': None, 'default': None, 'description': 'This listener is triggered when the user clears the VideoSlider using the clear button for the component.'}}}, '__meta__': {'additional_interfaces': {}, 'user_fn_refs': {'VideoSlider': []}}}
7
 
8
  abs_path = os.path.join(os.path.dirname(__file__), "css.css")
9
 
 
21
  # `gradio_videoslider`
22
 
23
  <div style="display: flex; gap: 7px;">
24
+ <a href="https://pypi.org/project/gradio_videoslider/" target="_blank"><img alt="PyPI - Version" src="https://img.shields.io/pypi/v/gradio_videoslider"></a>
25
  </div>
26
 
27
  VideoSlider Component for Gradio
 
38
  ## Usage
39
 
40
  ```python
 
41
  import gradio as gr
42
  from gradio_videoslider import VideoSlider
43
  import os
 
47
  # IMPORTANT: Replace the values below with the paths to YOUR video files.
48
  #
49
  # Option A: Relative Path (if the video is in the same folder as this app.py)
50
+ # video_path_1 = "video_before.mp4"
51
+ # video_path_2 = "video_after.mp4"
52
  #
53
  # Option B: Absolute Path (the full path to the file on your computer)
54
  # Example for Windows:
 
62
  video_path_2 = "examples/SampleVideo 1280x720.mp4"
63
 
64
 
65
+ # --- 2. FUNCTION FOR THE UPLOAD EXAMPLE ---
66
  def process_uploaded_videos(video_inputs):
67
  \"\"\"This function handles the uploaded videos.\"\"\"
68
  print("Received videos from upload:", video_inputs)
 
78
  # --- TAB 1: UPLOAD EXAMPLE ---
79
  with gr.TabItem("1. Compare via Upload"):
80
  gr.Markdown("## Upload two videos to compare them side-by-side.")
81
+ video_slider_input = VideoSlider(label="Your Videos", height=400, width=700, video_mode="upload")
82
  video_slider_output = VideoSlider(
83
  label="Video comparision",
84
  interactive=False,
85
+ autoplay=True,
86
+ video_mode="preview",
87
+ show_download_button=False,
88
  loop=True,
89
  height=400,
90
  width=700
 
105
  label="Video comparision",
106
  value=(video_path_1, video_path_2),
107
  interactive=False,
108
+ show_download_button=False,
109
  autoplay=True,
110
+ video_mode="preview",
111
  loop=True,
112
  height=400,
113
  width=700
114
  )
115
 
116
+ # A check to give a helpful error message if files are not found.
117
  if not os.path.exists(video_path_1) or not os.path.exists(video_path_2):
118
  print("---")
119
  print(f"WARNING: Could not find one or both video files.")
 
123
  print("---")
124
 
125
  if __name__ == '__main__':
126
+ demo.launch(debug=True)
127
 
128
  ```
129
  """, elem_classes=["md-custom"], header_links=True)
src/examples/SampleVideo 720x480.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:99d1ffc857ae1c12f0c378e7503a0057232e920ab6666a35f55caab37d43322a
3
+ size 564010
src/frontend/Index.svelte CHANGED
@@ -1,16 +1,16 @@
1
  <!-- videoslider/frontend/Index.svelte -->
2
- <!-- Enable programmatic access to the component's props from outside. -->
3
  <svelte:options accessors={true} />
4
 
5
  <script lang="ts">
6
- // Import Gradio types and common components.
 
7
  import type { Gradio } from "@gradio/utils";
8
  import { Block } from "@gradio/atoms";
9
  import { StatusTracker } from "@gradio/statustracker";
10
  import type { FileData } from "@gradio/client";
11
  import type { LoadingStatus } from "@gradio/statustracker";
12
 
13
- // Import the two main views for this component.
14
  import VideoSliderPreview from "./shared/VideoSliderPreview.svelte";
15
  import InteractiveVideoSlider from "./shared/InteractiveVideoSlider.svelte";
16
 
@@ -31,10 +31,12 @@
31
  export let scale: number | null = null;
32
  export let min_width: number | undefined = undefined;
33
  export let loading_status: LoadingStatus;
34
- /** If false, the component is in display-only mode. */
35
  export let interactive: boolean;
36
  export let show_download_button: boolean;
37
  export let show_fullscreen_button: boolean;
 
 
 
38
  /** The initial position of the slider, from 0 to 100. */
39
  export let position: number;
40
  export let autoplay: boolean;
@@ -56,11 +58,12 @@
56
  let old_value: [FileData | null, FileData | null] = [null, null];
57
  /** Tracks if the user is dragging over the upload area. */
58
  let dragging = false;
59
-
60
  // -----------------
61
  // Reactive Logic
62
  // -----------------
63
- /** Converts the 0-100 position from Python to a 0-1 scale for CSS. */
 
64
  $: normalised_slider_position = Math.max(0, Math.min(100, position)) / 100;
65
 
66
  /** Dispatches a 'change' event whenever the `value` prop is updated from within the component. */
@@ -70,17 +73,24 @@
70
  gradio.dispatch("change");
71
  }
72
  }
 
 
 
 
 
 
 
 
 
 
 
73
  </script>
74
 
75
- <!--
76
- This block acts as a router.
77
- It displays one of two sub-components based on the `interactive` prop from the backend.
78
- -->
79
- {#if !interactive}
80
- <!-- Static/Output Mode: Shows the side-by-side video player with a slider. -->
81
  <Block
82
  {visible}
83
  variant={"solid"}
 
84
  padding={false}
85
  {elem_id}
86
  {elem_classes}
@@ -100,22 +110,23 @@
100
  <VideoSliderPreview
101
  bind:value
102
  {interactive}
 
103
  {label}
104
  {show_label}
105
  {show_download_button}
106
  {show_fullscreen_button}
 
107
  i18n={gradio.i18n}
108
- position={normalised_slider_position}
109
  slider_color="var(--border-color-primary)"
110
  on:clear={() => gradio.dispatch("clear")}
111
- on:fullscreen={({ detail }) => { fullscreen = detail; }}
112
  {autoplay}
113
  {loop}
114
  upload={(...args) => gradio.client.upload(...args)}
115
  />
116
  </Block>
117
  {:else}
118
- <!-- Interactive/Input Mode: Shows two upload boxes for the user to add videos. -->
119
  <Block
120
  {visible}
121
  variant={"solid"}
@@ -145,10 +156,10 @@
145
  {show_label}
146
  max_file_size={gradio.max_file_size}
147
  i18n={gradio.i18n}
148
- upload={(...args) => gradio.client.upload(...args)}
149
- stream_handler={gradio.client?.stream}
150
  {autoplay}
151
- {loop}
152
  />
153
  </Block>
154
  {/if}
 
1
  <!-- videoslider/frontend/Index.svelte -->
 
2
  <svelte:options accessors={true} />
3
 
4
  <script lang="ts">
5
+ // Svelte and Gradio imports
6
+ import { tick } from "svelte";
7
  import type { Gradio } from "@gradio/utils";
8
  import { Block } from "@gradio/atoms";
9
  import { StatusTracker } from "@gradio/statustracker";
10
  import type { FileData } from "@gradio/client";
11
  import type { LoadingStatus } from "@gradio/statustracker";
12
 
13
+ // Local component imports
14
  import VideoSliderPreview from "./shared/VideoSliderPreview.svelte";
15
  import InteractiveVideoSlider from "./shared/InteractiveVideoSlider.svelte";
16
 
 
31
  export let scale: number | null = null;
32
  export let min_width: number | undefined = undefined;
33
  export let loading_status: LoadingStatus;
 
34
  export let interactive: boolean;
35
  export let show_download_button: boolean;
36
  export let show_fullscreen_button: boolean;
37
+ export let show_mute_button: boolean;
38
+ /** Determines whether to show the upload interface or the preview player. */
39
+ export let video_mode: "upload" | "preview" = "preview";
40
  /** The initial position of the slider, from 0 to 100. */
41
  export let position: number;
42
  export let autoplay: boolean;
 
58
  let old_value: [FileData | null, FileData | null] = [null, null];
59
  /** Tracks if the user is dragging over the upload area. */
60
  let dragging = false;
61
+
62
  // -----------------
63
  // Reactive Logic
64
  // -----------------
65
+
66
+ /** Converts the 0-100 position from Python to a 0-1 scale for the child component. */
67
  $: normalised_slider_position = Math.max(0, Math.min(100, position)) / 100;
68
 
69
  /** Dispatches a 'change' event whenever the `value` prop is updated from within the component. */
 
73
  gradio.dispatch("change");
74
  }
75
  }
76
+
77
+ /**
78
+ * Handles the fullscreen event from the child component.
79
+ * It updates the internal state and resets the slider position to the center.
80
+ * @param detail The new fullscreen state (true or false).
81
+ */
82
+ function handle_fullscreen_change(detail: boolean) {
83
+ fullscreen = detail;
84
+ position = 50; // Center the slider on fullscreen change
85
+ tick().then(() => gradio.dispatch("change"));
86
+ }
87
  </script>
88
 
89
+ {#if video_mode=="preview"}
 
 
 
 
 
90
  <Block
91
  {visible}
92
  variant={"solid"}
93
+ border_mode={dragging ? "focus" : "base"}
94
  padding={false}
95
  {elem_id}
96
  {elem_classes}
 
110
  <VideoSliderPreview
111
  bind:value
112
  {interactive}
113
+ {fullscreen}
114
  {label}
115
  {show_label}
116
  {show_download_button}
117
  {show_fullscreen_button}
118
+ {show_mute_button}
119
  i18n={gradio.i18n}
120
+ bind:position={normalised_slider_position}
121
  slider_color="var(--border-color-primary)"
122
  on:clear={() => gradio.dispatch("clear")}
123
+ on:fullscreen={({ detail }) => handle_fullscreen_change(detail)}
124
  {autoplay}
125
  {loop}
126
  upload={(...args) => gradio.client.upload(...args)}
127
  />
128
  </Block>
129
  {:else}
 
130
  <Block
131
  {visible}
132
  variant={"solid"}
 
156
  {show_label}
157
  max_file_size={gradio.max_file_size}
158
  i18n={gradio.i18n}
159
+ upload={(...args) => gradio.client.upload(...args)}
160
+ stream_handler={gradio.client?.stream}
161
  {autoplay}
162
+ {loop}
163
  />
164
  </Block>
165
  {/if}
src/frontend/shared/InteractiveVideo.svelte CHANGED
@@ -33,7 +33,7 @@ It's a building block for more complex components like the InteractiveVideoSlide
33
  export let show_label = true;
34
  export let webcam_options: WebcamOptions;
35
  export let include_audio: boolean;
36
- export let autoplay: boolean;
37
  export let root: string;
38
  export let i18n: I18nFormatter;
39
  /** The currently selected input source. */
 
33
  export let show_label = true;
34
  export let webcam_options: WebcamOptions;
35
  export let include_audio: boolean;
36
+ export const autoplay = undefined
37
  export let root: string;
38
  export let i18n: I18nFormatter;
39
  /** The currently selected input source. */
src/frontend/shared/Player.svelte CHANGED
@@ -1,12 +1,8 @@
1
- <!--
2
- @component
3
- This component is a self-contained video player. It wraps the base <Video>
4
- component and adds a custom control bar for play/pause, timeline scrubbing,
5
- and fullscreen. It also integrates with VideoControls for editing features.
6
- -->
7
  <script lang="ts">
8
- import { createEventDispatcher } from "svelte";
9
- import { Play, Pause, Maximise, Undo } from "@gradio/icons";
 
10
  import Video from "./Video.svelte";
11
  import VideoControls from "./VideoControls.svelte";
12
  import type { FileData, Client } from "@gradio/client";
@@ -35,6 +31,7 @@ and fullscreen. It also integrates with VideoControls for editing features.
35
  export let value: FileData | null = null;
36
  export let handle_clear: () => void = () => {};
37
  export let has_change_history = false;
 
38
 
39
  const dispatch = createEventDispatcher<{
40
  play: undefined;
@@ -42,7 +39,7 @@ and fullscreen. It also integrates with VideoControls for editing features.
42
  stop: undefined;
43
  end: undefined;
44
  clear: undefined;
45
- loadeddata: undefined;
46
  }>();
47
 
48
  // -----------------
@@ -54,7 +51,7 @@ and fullscreen. It also integrates with VideoControls for editing features.
54
  let duration: number;
55
  /** The paused state of the video. */
56
  let paused = true;
57
- /** A direct reference to the underlying HTML <video> element. */
58
  let video: HTMLVideoElement;
59
  /** A prop to export the video element reference to parent components. */
60
  export let video_el: HTMLVideoElement;
@@ -62,53 +59,65 @@ and fullscreen. It also integrates with VideoControls for editing features.
62
  let processingVideo = false;
63
 
64
  // -----------------
65
- // Event Handlers
66
  // -----------------
67
 
68
- /** Handles scrubbing the video timeline with a mouse or touch drag. */
69
- function handleMove(e: TouchEvent | MouseEvent): void {
70
- if (!duration) return;
71
-
72
- if (e.type === "click") {
73
- handle_click(e as MouseEvent);
74
- return;
 
 
 
 
 
 
 
 
 
 
 
75
  }
 
 
 
 
 
 
 
 
 
76
 
77
- if (e.type !== "touchmove" && !((e as MouseEvent).buttons & 1)) return;
 
 
 
 
 
78
 
79
- const clientX =
80
- e.type === "touchmove"
81
- ? (e as TouchEvent).touches[0].clientX
82
- : (e as MouseEvent).clientX;
83
- const { left, right } = (
84
- e.currentTarget as HTMLProgressElement
85
- ).getBoundingClientRect();
86
- time = (duration * (clientX - left)) / (right - left);
87
  }
88
 
89
  /** Toggles the video between playing and paused states. */
90
  async function play_pause(): Promise<void> {
91
- if (document.fullscreenElement != video) {
92
- const isPlaying =
93
- video.currentTime > 0 &&
94
- !video.paused &&
95
- !video.ended &&
96
- video.readyState > video.HAVE_CURRENT_DATA;
97
-
98
- if (!isPlaying) {
99
- await video.play();
100
- } else video.pause();
101
  }
102
  }
103
 
104
- /** Handles a single click on the progress bar to seek to a specific time. */
105
- function handle_click(e: MouseEvent): void {
106
- const { left, right } = (
107
- e.currentTarget as HTMLProgressElement
108
- ).getBoundingClientRect();
109
- time = (duration * (e.clientX - left)) / (right - left);
110
- }
111
-
112
  /** Dispatches events when the video playback ends. */
113
  function handle_end(): void {
114
  dispatch("stop");
@@ -120,26 +129,43 @@ and fullscreen. It also integrates with VideoControls for editing features.
120
  let _video_blob = new File([videoBlob], "video.mp4");
121
  const val = await prepare_files([_video_blob]);
122
  let value = ((await upload(val, root))?.filter(Boolean) as FileData[])[0];
123
-
124
  handle_change(value);
125
  };
126
 
127
- /** Enters fullscreen mode for the video. */
128
- function open_full_screen(): void {
129
- video.requestFullscreen();
130
- }
131
-
132
  // -----------------
133
  // Reactive Logic
134
  // -----------------
135
- /** Ensures `time` and `duration` are never NaN. */
136
  $: time = time || 0;
137
  $: duration = duration || 0;
138
  /** Passes the internal video element reference to the exported prop. */
139
  $: video_el = video;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  </script>
141
 
142
  <div class="wrap">
 
 
 
143
  <div class="mirror-wrap" class:mirror>
144
  <Video
145
  {src}
@@ -150,8 +176,9 @@ and fullscreen. It also integrates with VideoControls for editing features.
150
  on:click={play_pause}
151
  on:play={() => dispatch("play")}
152
  on:pause={() => dispatch("pause")}
153
- on:loadeddata={() => dispatch("loadeddata")}
154
- on:error
 
155
  on:ended={handle_end}
156
  bind:currentTime={time}
157
  bind:duration
@@ -159,13 +186,12 @@ and fullscreen. It also integrates with VideoControls for editing features.
159
  bind:node={video}
160
  data-testid={`${label}-player`}
161
  {processingVideo}
 
162
  >
163
  <track kind="captions" src={subtitle} default />
164
  </Video>
165
-
166
  </div>
167
 
168
- <!-- The custom video controls overlay -->
169
  <div class="controls">
170
  <div class="inner">
171
  <span
@@ -174,7 +200,9 @@ and fullscreen. It also integrates with VideoControls for editing features.
174
  class="icon"
175
  aria-label="play-pause-replay-button"
176
  on:click={play_pause}
177
- on:keydown={play_pause}
 
 
178
  >
179
  {#if time === duration}
180
  <Undo />
@@ -184,31 +212,21 @@ and fullscreen. It also integrates with VideoControls for editing features.
184
  <Pause />
185
  {/if}
186
  </span>
187
-
188
  <span class="time">{format_time(time)} / {format_time(duration)}</span>
189
-
190
- <progress
191
- value={time / duration || 0}
192
- on:mousemove={handleMove}
193
- on:touchmove|preventDefault={handleMove}
194
- on:click|stopPropagation|preventDefault={handle_click}
 
 
 
195
  />
196
-
197
- <div
198
- role="button"
199
- tabindex="0"
200
- class="icon"
201
- aria-label="full-screen"
202
- on:click={open_full_screen}
203
- on:keypress={open_full_screen}
204
- >
205
- <Maximise />
206
- </div>
207
  </div>
208
  </div>
209
  </div>
210
 
211
- <!-- If in interactive mode, show the additional editing controls below the player. -->
212
  {#if interactive}
213
  <VideoControls
214
  videoElement={video}
@@ -228,34 +246,19 @@ and fullscreen. It also integrates with VideoControls for editing features.
228
  span {
229
  text-shadow: 0 0 8px rgba(0, 0, 0, 0.5);
230
  }
231
-
232
- progress {
233
  margin-right: var(--size-3);
234
- border-radius: var(--radius-sm);
235
  width: var(--size-full);
236
  height: var(--size-2);
237
  }
238
-
239
- progress::-webkit-progress-bar {
240
- border-radius: 2px;
241
- background-color: rgba(255, 255, 255, 0.2);
242
- overflow: hidden;
243
- }
244
-
245
- progress::-webkit-progress-value {
246
- background-color: rgba(255, 255, 255, 0.9);
247
- }
248
-
249
  .mirror {
250
  transform: scaleX(-1);
251
  }
252
-
253
  .mirror-wrap {
254
  position: relative;
255
  height: 100%;
256
  width: 100%;
257
  }
258
-
259
  .controls {
260
  position: absolute;
261
  bottom: 0;
@@ -270,7 +273,6 @@ and fullscreen. It also integrates with VideoControls for editing features.
270
  .wrap:hover .controls {
271
  opacity: 1;
272
  }
273
-
274
  .inner {
275
  display: flex;
276
  justify-content: space-between;
@@ -280,7 +282,6 @@ and fullscreen. It also integrates with VideoControls for editing features.
280
  width: var(--size-full);
281
  height: var(--size-full);
282
  }
283
-
284
  .icon {
285
  display: flex;
286
  justify-content: center;
@@ -288,7 +289,6 @@ and fullscreen. It also integrates with VideoControls for editing features.
288
  width: var(--size-6);
289
  color: white;
290
  }
291
-
292
  .time {
293
  flex-shrink: 0;
294
  margin-right: var(--size-3);
@@ -307,5 +307,16 @@ and fullscreen. It also integrates with VideoControls for editing features.
307
  .wrap :global(video) {
308
  height: var(--size-full);
309
  width: var(--size-full);
 
 
 
 
 
 
 
 
 
 
 
310
  }
311
  </style>
 
1
+ <!-- videoslider/frontend/shared/Player.svelte -->
 
 
 
 
 
2
  <script lang="ts">
3
+ // Svelte and Gradio imports
4
+ import { createEventDispatcher, onMount, tick } from "svelte";
5
+ import { Play, Pause, Undo } from "@gradio/icons";
6
  import Video from "./Video.svelte";
7
  import VideoControls from "./VideoControls.svelte";
8
  import type { FileData, Client } from "@gradio/client";
 
31
  export let value: FileData | null = null;
32
  export let handle_clear: () => void = () => {};
33
  export let has_change_history = false;
34
+ export let fullscreen = false;
35
 
36
  const dispatch = createEventDispatcher<{
37
  play: undefined;
 
39
  stop: undefined;
40
  end: undefined;
41
  clear: undefined;
42
+ load: { top: number; left: number; width: number; height: number };
43
  }>();
44
 
45
  // -----------------
 
51
  let duration: number;
52
  /** The paused state of the video. */
53
  let paused = true;
54
+ /** A direct reference to the underlying HTML <video> element for internal use. */
55
  let video: HTMLVideoElement;
56
  /** A prop to export the video element reference to parent components. */
57
  export let video_el: HTMLVideoElement;
 
59
  let processingVideo = false;
60
 
61
  // -----------------
62
+ // Functions
63
  // -----------------
64
 
65
+ /**
66
+ * Calculates and returns the size and relative position of the video element.
67
+ * @param video The HTML video element to measure.
68
+ */
69
+ function get_video_size(video: HTMLVideoElement | null) {
70
+ if (!video) {
71
+ const container = video?.parentElement?.getBoundingClientRect() || {
72
+ top: 0,
73
+ left: 0,
74
+ width: 640,
75
+ height: 360
76
+ };
77
+ return {
78
+ top: 0,
79
+ left: 0,
80
+ width: container.width,
81
+ height: container.height
82
+ };
83
  }
84
+ const rect = video.getBoundingClientRect();
85
+ const containerRect = video.parentElement?.getBoundingClientRect() || rect;
86
+ return {
87
+ top: rect.top - containerRect.top,
88
+ left: rect.left - containerRect.left,
89
+ width: rect.width,
90
+ height: rect.height
91
+ };
92
+ }
93
 
94
+ /** Handles user input on the range slider to seek the video. */
95
+ function handleMove(e: Event): void {
96
+ if (!duration) return;
97
+ const input = e.currentTarget as HTMLInputElement;
98
+ time = duration * (parseFloat(input.value) / 100);
99
+ }
100
 
101
+ /** Handles keyboard navigation (arrow keys) on the range slider. */
102
+ function handleKeydown(e: KeyboardEvent): void {
103
+ if (!duration) return;
104
+ if (e.key === "ArrowLeft") {
105
+ time = Math.max(0, time - 5);
106
+ } else if (e.key === "ArrowRight") {
107
+ time = Math.min(duration, time + 5);
108
+ }
109
  }
110
 
111
  /** Toggles the video between playing and paused states. */
112
  async function play_pause(): Promise<void> {
113
+ const isPlaying = video.currentTime > 0 && !video.paused && !video.ended && video.readyState > video.HAVE_CURRENT_DATA;
114
+ if (!isPlaying) {
115
+ await video.play();
116
+ } else {
117
+ video.pause();
 
 
 
 
 
118
  }
119
  }
120
 
 
 
 
 
 
 
 
 
121
  /** Dispatches events when the video playback ends. */
122
  function handle_end(): void {
123
  dispatch("stop");
 
129
  let _video_blob = new File([videoBlob], "video.mp4");
130
  const val = await prepare_files([_video_blob]);
131
  let value = ((await upload(val, root))?.filter(Boolean) as FileData[])[0];
 
132
  handle_change(value);
133
  };
134
 
 
 
 
 
 
135
  // -----------------
136
  // Reactive Logic
137
  // -----------------
 
138
  $: time = time || 0;
139
  $: duration = duration || 0;
140
  /** Passes the internal video element reference to the exported prop. */
141
  $: video_el = video;
142
+ /** Calculates the progress value (0-100) for the range slider. */
143
+ $: progressValue = duration ? (time / duration) * 100 : 0;
144
+
145
+ /** When the video element is available, dispatch its size and set up a ResizeObserver. */
146
+ onMount(() => {
147
+ const resizer = new ResizeObserver(async () => {
148
+ await tick();
149
+ dispatch("load", get_video_size(video));
150
+ });
151
+ if (video) {
152
+ resizer.observe(video);
153
+ }
154
+ return () => {
155
+ resizer.disconnect();
156
+ };
157
+ });
158
+
159
+ /** Dispatches the video size whenever the video element reference changes. */
160
+ $: if (video) {
161
+ dispatch("load", get_video_size(video));
162
+ }
163
  </script>
164
 
165
  <div class="wrap">
166
+ {#if !video?.videoWidth}
167
+ <div class="loading-spinner">Loading video...</div>
168
+ {/if}
169
  <div class="mirror-wrap" class:mirror>
170
  <Video
171
  {src}
 
176
  on:click={play_pause}
177
  on:play={() => dispatch("play")}
178
  on:pause={() => dispatch("pause")}
179
+ on:loadeddata={() => dispatch("load", get_video_size(video))}
180
+ on:loadedmetadata={() => dispatch("load", get_video_size(video))}
181
+ on:error={(e) => dispatch("error", e)}
182
  on:ended={handle_end}
183
  bind:currentTime={time}
184
  bind:duration
 
186
  bind:node={video}
187
  data-testid={`${label}-player`}
188
  {processingVideo}
189
+ {fullscreen}
190
  >
191
  <track kind="captions" src={subtitle} default />
192
  </Video>
 
193
  </div>
194
 
 
195
  <div class="controls">
196
  <div class="inner">
197
  <span
 
200
  class="icon"
201
  aria-label="play-pause-replay-button"
202
  on:click={play_pause}
203
+ on:keydown={(e) => {
204
+ if (e.key === "Enter" || e.key === " ") play_pause();
205
+ }}
206
  >
207
  {#if time === duration}
208
  <Undo />
 
212
  <Pause />
213
  {/if}
214
  </span>
 
215
  <span class="time">{format_time(time)} / {format_time(duration)}</span>
216
+ <input
217
+ type="range"
218
+ min="0"
219
+ max="100"
220
+ step="0.1"
221
+ value={progressValue}
222
+ aria-label="Video progress"
223
+ on:input={handleMove}
224
+ on:keydown={handleKeydown}
225
  />
 
 
 
 
 
 
 
 
 
 
 
226
  </div>
227
  </div>
228
  </div>
229
 
 
230
  {#if interactive}
231
  <VideoControls
232
  videoElement={video}
 
246
  span {
247
  text-shadow: 0 0 8px rgba(0, 0, 0, 0.5);
248
  }
249
+ input[type="range"] {
 
250
  margin-right: var(--size-3);
 
251
  width: var(--size-full);
252
  height: var(--size-2);
253
  }
 
 
 
 
 
 
 
 
 
 
 
254
  .mirror {
255
  transform: scaleX(-1);
256
  }
 
257
  .mirror-wrap {
258
  position: relative;
259
  height: 100%;
260
  width: 100%;
261
  }
 
262
  .controls {
263
  position: absolute;
264
  bottom: 0;
 
273
  .wrap:hover .controls {
274
  opacity: 1;
275
  }
 
276
  .inner {
277
  display: flex;
278
  justify-content: space-between;
 
282
  width: var(--size-full);
283
  height: var(--size-full);
284
  }
 
285
  .icon {
286
  display: flex;
287
  justify-content: center;
 
289
  width: var(--size-6);
290
  color: white;
291
  }
 
292
  .time {
293
  flex-shrink: 0;
294
  margin-right: var(--size-3);
 
307
  .wrap :global(video) {
308
  height: var(--size-full);
309
  width: var(--size-full);
310
+ object-fit: contain;
311
+ }
312
+ .loading-spinner {
313
+ position: absolute;
314
+ top: 50%;
315
+ left: 50%;
316
+ transform: translate(-50%, -50%);
317
+ color: white;
318
+ background: rgba(0,0,0,0.7);
319
+ padding: 1rem;
320
+ border-radius: 0.5rem;
321
  }
322
  </style>
src/frontend/shared/Slider.svelte CHANGED
@@ -1,249 +1,227 @@
1
- <!--
2
- @component
3
- A general-purpose draggable slider component that can be laid over other content.
4
- It uses d3-drag for robust drag handling and is designed to differentiate
5
- between a "drag" action and a "click" action.
6
- -->
7
- <script lang="ts">
8
- import { onMount, createEventDispatcher } from "svelte";
9
- import { drag } from "d3-drag";
10
- import { select } from "d3-selection";
11
-
12
- /** A utility function to constrain a value within a minimum and maximum range. */
13
- function clamp(value: number, min: number, max: number): number {
14
- return Math.min(Math.max(value, min), max);
15
- }
16
-
17
- // ------------------
18
- // Props
19
- // ------------------
20
- /** The slider's position as a normalized value (0 to 1). Can be two-way bound. */
21
- export let position = 0.5;
22
- /** If true, disables all dragging and interaction. */
23
- export let disabled = false;
24
- /** The color of the vertical slider line. */
25
- export let slider_color = "var(--border-color-primary)";
26
- /** Layout-related props, typically bound from the parent, used for calculating pixel positions. */
27
- export let image_size: {
28
- top: number;
29
- left: number;
30
- width: number;
31
- height: number;
32
- } = { top: 0, left: 0, width: 0, height: 0 };
33
- export let el: HTMLDivElement | undefined = undefined;
34
- export let parent_el: HTMLDivElement | undefined = undefined;
35
-
36
- /** Dispatches a 'click' event ONLY if the user clicks the handle without dragging. */
37
- const dispatch = createEventDispatcher<{ click: MouseEvent }>();
38
-
39
- // -----------------
40
- // Internal State
41
- // -----------------
42
- let inner: Element;
43
- /** The slider's horizontal position in pixels. */
44
- let px = 0;
45
- /** True while the user is actively dragging the handle. */
46
- let active = false;
47
- let container_width = 0;
48
-
49
- let drag_start_x: number;
50
- /** A flag to determine if a drag action actually moved, differentiating it from a simple click. */
51
- let was_dragged = false;
52
-
53
- // -----------------
54
- // Functions
55
- // -----------------
56
-
57
- /** Calculates the slider's pixel position based on its container's dimensions. */
58
- function set_position(width: number): void {
59
- container_width = parent_el?.getBoundingClientRect().width || 0;
60
- if (width === 0) {
61
- image_size.width = el?.getBoundingClientRect().width || 0;
62
- }
63
- px = clamp(
64
- image_size.width * position + image_size.left,
65
- 0,
66
- container_width
67
- );
68
- }
69
-
70
- function round(n: number, points: number): number {
71
- const mod = Math.pow(10, points);
72
- return Math.round((n + Number.EPSILON) * mod) / mod;
73
- }
74
-
75
- /** Updates the internal state based on the drag's x-coordinate. */
76
- function update_position(x: number): void {
77
- px = clamp(x, 0, container_width);
78
- position = round((x - image_size.left) / image_size.width, 5);
79
- }
80
-
81
- // --- D3 Drag Event Handlers ---
82
-
83
- function drag_start(event: any): void {
84
- if (disabled) return;
85
- active = true;
86
- update_position(event.x);
87
- drag_start_x = event.x;
88
- was_dragged = false;
89
- }
90
-
91
- function drag_move(event: any): void {
92
- if (disabled) return;
93
- update_position(event.x);
94
- if (Math.abs(event.x - drag_start_x) > 3) {
95
- was_dragged = true;
96
- }
97
- }
98
-
99
- function drag_end(event: any): void {
100
- if (disabled) return;
101
- active = false;
102
- if (!was_dragged) {
103
- dispatch("click", event.sourceEvent);
104
- }
105
- }
106
-
107
- /** Updates the pixel position when the `position` prop changes from the parent. */
108
- function update_position_from_pc(pc: number): void {
109
- px = clamp(image_size.width * pc + image_size.left, 0, container_width);
110
- }
111
-
112
- // -----------------
113
- // Lifecycle & Reactive Logic
114
- // -----------------
115
- $: set_position(image_size.width);
116
- $: update_position_from_pc(position);
117
-
118
- onMount(() => {
119
- set_position(image_size.width);
120
- const drag_handler = drag()
121
- .on("start", drag_start)
122
- .on("drag", drag_move)
123
- .on("end", drag_end);
124
- select(inner).call(drag_handler);
125
- });
126
- </script>
127
-
128
- <!-- Recalculate position on window resize. -->
129
- <svelte:window on:resize={() => set_position(image_size.width)} />
130
-
131
- <div class="wrap" role="none" bind:this={parent_el}>
132
- <!-- The content from the parent component is placed here. -->
133
- <div class="content" bind:this={el}>
134
- <slot />
135
- </div>
136
-
137
- <!-- This is the draggable handle area. -->
138
- <div
139
- class="outer"
140
- class:disabled
141
- bind:this={inner}
142
- role="none"
143
- style="transform: translateX({px}px)"
144
- class:grab={active}
145
- on:click={(event) => event.stopPropagation()}
146
- >
147
- <span class="icon-wrap" class:active class:disabled>
148
- <span class="icon left">◢</span>
149
- <span class="icon center" style:--color={slider_color}></span>
150
- <span class="icon right">◢</span>
151
- </span>
152
- <!-- This is the visible vertical line of the slider. -->
153
- <div class="inner" style:--color={slider_color}></div>
154
- </div>
155
- </div>
156
-
157
- <style>
158
- .wrap {
159
- position: relative;
160
- width: 100%;
161
- height: 100%;
162
- z-index: var(--layer-1);
163
- overflow: hidden;
164
- }
165
-
166
- .icon-wrap {
167
- display: block;
168
- position: absolute;
169
- top: 50%;
170
- transform: translate(-20.5px, -50%);
171
- left: 10px;
172
- width: 40px;
173
- transition: 0.2s;
174
- color: var(--body-text-color);
175
- height: 30px;
176
- border-radius: 5px;
177
- background-color: var(--color-accent);
178
- display: flex;
179
- align-items: center;
180
- justify-content: center;
181
- z-index: var(--layer-3);
182
- box-shadow: 0px 0px 5px 2px rgba(0, 0, 0, 0.3);
183
- font-size: 12px;
184
- }
185
-
186
- .icon.left {
187
- transform: rotate(135deg);
188
- text-shadow: -1px -1px 1px rgba(0, 0, 0, 0.1);
189
- }
190
-
191
- .icon.right {
192
- transform: rotate(-45deg);
193
- text-shadow: -1px -1px 1px rgba(0, 0, 0, 0.1);
194
- }
195
-
196
- .icon.center {
197
- display: block;
198
- width: 1px;
199
- height: 100%;
200
- background-color: var(--color);
201
- opacity: 0.1;
202
- }
203
-
204
- .icon-wrap.active {
205
- opacity: 0;
206
- }
207
-
208
- .icon-wrap.disabled {
209
- opacity: 0;
210
- }
211
-
212
- .outer {
213
- width: 20px;
214
- height: 100%;
215
- position: absolute;
216
- cursor: grab;
217
- top: 0;
218
- left: -10px;
219
- pointer-events: auto;
220
- z-index: var(--layer-2);
221
- }
222
- .grab {
223
- cursor: grabbing;
224
- }
225
-
226
- .inner {
227
- width: 1px;
228
- height: 100%;
229
- background: var(--color);
230
- position: absolute;
231
- left: calc((100% - 2px) / 2);
232
- }
233
-
234
- .disabled {
235
- cursor: auto;
236
- }
237
-
238
- .disabled .inner {
239
- box-shadow: none;
240
- }
241
-
242
- .content {
243
- width: 100%;
244
- height: 100%;
245
- display: flex;
246
- justify-content: center;
247
- align-items: center;
248
- }
249
  </style>
 
1
+ <!-- videoslider/frontend/shared/Slider.svelte -->
2
+ <script lang="ts">
3
+ // Svelte and D3 imports
4
+ import { onMount, tick } from "svelte";
5
+ import { drag } from "d3-drag";
6
+ import { select } from "d3-selection";
7
+
8
+ /** A utility function to constrain a value within a minimum and maximum range. */
9
+ function clamp(value: number, min: number, max: number): number {
10
+ return Math.min(Math.max(value, min), max);
11
+ }
12
+
13
+ // ------------------
14
+ // Props
15
+ // ------------------
16
+ /** The slider's position as a normalized value (0 to 1). Can be two-way bound. */
17
+ export let position = 0.5;
18
+ /** If true, disables all dragging and interaction. */
19
+ export let disabled = false;
20
+ /** The color of the vertical slider line. */
21
+ export let slider_color = "var(--border-color-primary)";
22
+ /** The dimensions of the content being compared. */
23
+ export let image_size: { top: number; left: number; width: number; height: number };
24
+ /** A reference to the content element. */
25
+ export let el: HTMLDivElement | undefined = undefined;
26
+ /** A reference to the main wrapper element. */
27
+ export let parent_el: HTMLDivElement | undefined = undefined;
28
+
29
+ // -----------------
30
+ // Internal State
31
+ // -----------------
32
+ /** A reference to the draggable handle element. */
33
+ let inner: HTMLDivElement | undefined;
34
+ /** The slider's horizontal position in pixels. */
35
+ let px = 0;
36
+ /** True while the user is actively dragging the handle. */
37
+ let active = false;
38
+ let container_width = 0;
39
+
40
+ /**
41
+ * Calculates and sets the slider's pixel position based on its container's dimensions.
42
+ * This is called reactively and by the ResizeObserver.
43
+ */
44
+ function set_position(): void {
45
+ if (!parent_el) return;
46
+ const rect = parent_el.getBoundingClientRect();
47
+ container_width = rect.width;
48
+ px = clamp(container_width * position, 0, container_width);
49
+ }
50
+
51
+ /** A utility function to round a number to a specific number of decimal points. */
52
+ function round(n: number, points: number): number {
53
+ const mod = Math.pow(10, points);
54
+ return Math.round((n + Number.EPSILON) * mod) / mod;
55
+ }
56
+
57
+ /** Updates the internal state based on the drag's x-coordinate in pixels. */
58
+ function update_position(x: number): void {
59
+ if (!parent_el || !image_size?.width) return;
60
+ container_width = parent_el.getBoundingClientRect().width;
61
+ px = clamp(x, 0, container_width);
62
+ position = round((px - image_size.left) / image_size.width, 5);
63
+ }
64
+
65
+ // -----------------
66
+ // D3 Drag Handlers
67
+ // -----------------
68
+
69
+ /** Handles the start of a drag action. */
70
+ function drag_start(event: any): void {
71
+ if (disabled) return;
72
+ active = true;
73
+ update_position(event.x);
74
+ }
75
+
76
+ /** Handles the movement during a drag action. */
77
+ function drag_move(event: any): void {
78
+ if (disabled) return;
79
+ update_position(event.x);
80
+ }
81
+
82
+ /** Handles the end of a drag action. */
83
+ function drag_end(): void {
84
+ if (disabled) return;
85
+ active = false;
86
+ }
87
+
88
+ // -----------------
89
+ // Reactive Logic & Lifecycle
90
+ // -----------------
91
+
92
+ /** Reactively updates the slider's pixel position whenever the normalized `position` changes. */
93
+ $: set_position();
94
+
95
+ /** Reactively applies the calculated pixel position to the handle's style. */
96
+ $: if (inner) {
97
+ inner.style.transform = `translateX(${px}px)`;
98
+ }
99
+
100
+ /** On mount, sets up the d3-drag handler and a ResizeObserver to keep the layout correct. */
101
+ onMount(() => {
102
+ if (!inner) return;
103
+
104
+ const drag_handler = drag()
105
+ .on("start", drag_start)
106
+ .on("drag", drag_move)
107
+ .on("end", drag_end)
108
+ .touchable(() => true);
109
+
110
+ select(inner).call(drag_handler);
111
+
112
+ const resizeObserver = new ResizeObserver(() => {
113
+ tick().then(() => set_position());
114
+ });
115
+
116
+ if (parent_el) {
117
+ resizeObserver.observe(parent_el);
118
+ }
119
+
120
+ return () => {
121
+ resizeObserver.disconnect();
122
+ };
123
+ });
124
+ </script>
125
+
126
+ <svelte:window on:resize={() => {
127
+ tick().then(() => set_position());
128
+ }} />
129
+
130
+ <div class="wrap" role="none" bind:this={parent_el}>
131
+ <div class="content" bind:this={el}>
132
+ <slot />
133
+ </div>
134
+ <div
135
+ class="outer"
136
+ class:disabled
137
+ bind:this={inner}
138
+ role="none"
139
+ class:grab={active}
140
+ on:click|stopPropagation
141
+ >
142
+ <span class="icon-wrap" class:active class:disabled>
143
+ <span class="icon left">◢</span>
144
+ <span class="icon center" style:--color={slider_color}></span>
145
+ <span class="icon right">◢</span>
146
+ </span>
147
+ <div class="inner" style:--color={slider_color}></div>
148
+ </div>
149
+ </div>
150
+
151
+ <style>
152
+ .wrap {
153
+ position: relative;
154
+ width: 100%;
155
+ height: 100%;
156
+ z-index: var(--layer-1);
157
+ overflow: hidden;
158
+ }
159
+ .icon-wrap {
160
+ display: flex;
161
+ position: absolute;
162
+ top: 50%;
163
+ transform: translate(-50%, -50%);
164
+ left: 50%;
165
+ width: 40px;
166
+ transition: 0.2s;
167
+ color: var(--body-text-color);
168
+ height: 30px;
169
+ border-radius: 5px;
170
+ background-color: var(--color-accent);
171
+ align-items: center;
172
+ justify-content: center;
173
+ z-index: var(--layer-3);
174
+ box-shadow: 0px 0px 5px 2px rgba(0, 0, 0, 0.3);
175
+ font-size: 12px;
176
+ pointer-events: auto;
177
+ }
178
+ .icon.left {
179
+ transform: rotate(135deg);
180
+ text-shadow: -1px -1px 1px rgba(0, 0, 0, 0.1);
181
+ }
182
+ .icon.right {
183
+ transform: rotate(-45deg);
184
+ text-shadow: -1px -1px 1px rgba(0, 0, 0, 0.1);
185
+ }
186
+ .icon.center {
187
+ display: block;
188
+ width: 1px;
189
+ height: 100%;
190
+ background-color: var(--color);
191
+ opacity: 0.5;
192
+ }
193
+ .outer {
194
+ width: 40px;
195
+ height: 100%;
196
+ position: absolute;
197
+ cursor: grab;
198
+ top: 0;
199
+ left: -20px;
200
+ pointer-events: auto;
201
+ z-index: 1000;
202
+ }
203
+ .grab {
204
+ cursor: grabbing;
205
+ }
206
+ .inner {
207
+ width: 1px;
208
+ height: 100%;
209
+ background: var(--color);
210
+ position: absolute;
211
+ left: calc((100% - 1px) / 2);
212
+ }
213
+ .disabled {
214
+ cursor: not-allowed;
215
+ opacity: 0.5;
216
+ }
217
+ .disabled .inner {
218
+ box-shadow: none;
219
+ }
220
+ .content {
221
+ width: 100%;
222
+ height: 100%;
223
+ display: flex;
224
+ justify-content: center;
225
+ align-items: center;
226
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  </style>
src/frontend/shared/Video.svelte CHANGED
@@ -1,26 +1,17 @@
1
- <!--
2
- @component
3
- This is the foundational video component, a wrapper around the native HTML `<video>` tag.
4
- It adds advanced functionality like HLS streaming support and compatibility with
5
- Gradio's WebAssembly (Wasm) environment. It also forwards native video events
6
- to parent components.
7
- -->
8
  <svelte:options accessors={true} />
9
 
10
  <script lang="ts">
 
11
  import type { HTMLVideoAttributes } from "svelte/elements";
12
  import { createEventDispatcher } from "svelte";
13
  import { loaded } from "./utils";
14
-
15
  import { resolve_wasm_src } from "@gradio/wasm/svelte";
16
-
17
  import Hls from "hls.js";
18
 
19
  // ------------------
20
  // Props
21
  // ------------------
22
-
23
- // --- Standard Video Attributes ---
24
  export let src: HTMLVideoAttributes["src"] = undefined;
25
  export let muted: HTMLVideoAttributes["muted"] = undefined;
26
  export let playsinline: HTMLVideoAttributes["playsinline"] = undefined;
@@ -28,30 +19,29 @@ to parent components.
28
  export let autoplay: HTMLVideoAttributes["autoplay"] = undefined;
29
  export let controls: HTMLVideoAttributes["controls"] = undefined;
30
  export let loop: boolean;
31
-
32
- // --- Two-way Bound State ---
33
- export let currentTime: number | undefined = undefined;
34
- export let duration: number | undefined = undefined;
35
- export let paused: boolean | undefined = undefined;
36
-
37
- // --- Specialized Props ---
38
  /** A direct reference to the underlying HTML <video> element. */
39
  export let node: HTMLVideoElement | undefined = undefined;
40
  /** If true, the source is an HLS stream and will be handled by hls.js. */
41
- export let is_stream;
42
  /** If true, displays a loading overlay on the video. */
43
  export let processingVideo = false;
 
 
 
 
 
 
44
 
45
  // -----------------
46
  // Internal State
47
  // -----------------
48
  let resolved_src: typeof src;
49
  let stream_active = false;
50
-
51
- // This block handles resolving video sources in a WebAssembly (Wasm) environment.
52
- // It prevents UI flickering by showing the original `src` immediately while the
53
- // Wasm-compatible source is being resolved asynchronously.
54
  let latest_src: typeof src;
 
 
55
  $: {
56
  resolved_src = src;
57
  latest_src = src;
@@ -67,31 +57,22 @@ to parent components.
67
 
68
  /**
69
  * Initializes and attaches an HLS.js player to the video element for streaming.
70
- * This is only triggered when the `is_stream` prop is true.
71
  * @param src The URL of the HLS manifest (.m3u8 file).
72
  * @param is_stream A flag to enable or disable this functionality.
73
  * @param node The HTML video element to attach the stream to.
74
  */
75
- function load_stream(
76
- src: string | null | undefined,
77
- is_stream: boolean,
78
- node: HTMLVideoElement
79
- ): void {
80
  if (!src || !is_stream) return;
81
-
82
  if (Hls.isSupported() && !stream_active) {
83
  const hls = new Hls({
84
- // Low-latency configuration
85
  maxBufferLength: 1,
86
  maxMaxBufferLength: 1,
87
  lowLatencyMode: true
88
  });
89
  hls.loadSource(src);
90
  hls.attachMedia(node);
91
- hls.on(Hls.Events.MANIFEST_PARSED, function () {
92
- (node as HTMLVideoElement).play();
93
- });
94
- hls.on(Hls.Events.ERROR, function (event, data) {
95
  if (data.fatal) {
96
  switch (data.type) {
97
  case Hls.ErrorTypes.NETWORK_ERROR:
@@ -112,19 +93,12 @@ to parent components.
112
 
113
  /** Reset the HLS stream when the video source changes. */
114
  $: src, (stream_active = false);
115
-
116
  /** Trigger the HLS stream loader if the source is a stream. */
117
  $: if (node && src && is_stream) {
118
  load_stream(src, is_stream, node);
119
  }
120
  </script>
121
 
122
- <!--
123
- A necessary comment explaining a Svelte-specific quirk:
124
- The spread operator with `$$props` or `$$restProps` can't be used here
125
- to pass props from the parent component to the <video> element
126
- because of its unexpected behavior: https://github.com/sveltejs/svelte/issues/7404
127
- -->
128
  <div class:hidden={!processingVideo} class="overlay">
129
  <span class="load-wrap">
130
  <span class="loader" />
@@ -138,24 +112,19 @@ because of its unexpected behavior: https://github.com/sveltejs/svelte/issues/74
138
  {autoplay}
139
  {controls}
140
  {loop}
141
-
142
-
143
  on:loadeddata={dispatch.bind(null, "loadeddata")}
144
  on:click={dispatch.bind(null, "click")}
145
  on:play={dispatch.bind(null, "play")}
146
  on:pause={dispatch.bind(null, "pause")}
147
  on:ended={dispatch.bind(null, "ended")}
148
  on:error={dispatch.bind(null, "error", "Video not playable")}
149
-
150
-
151
  bind:currentTime
152
  bind:duration
153
  bind:paused
154
  bind:this={node}
155
-
156
-
157
  use:loaded={{ autoplay: autoplay ?? false }}
158
-
159
  data-testid={$$props["data-testid"]}
160
  crossorigin="anonymous"
161
  >
@@ -169,18 +138,15 @@ because of its unexpected behavior: https://github.com/sveltejs/svelte/issues/74
169
  width: 100%;
170
  height: 100%;
171
  }
172
-
173
  .hidden {
174
  display: none;
175
  }
176
-
177
  .load-wrap {
178
  display: flex;
179
  justify-content: center;
180
  align-items: center;
181
  height: 100%;
182
  }
183
-
184
  .loader {
185
  display: flex;
186
  position: relative;
@@ -195,7 +161,6 @@ because of its unexpected behavior: https://github.com/sveltejs/svelte/issues/74
195
  height: 10px;
196
  scale: 0.5;
197
  }
198
-
199
  @keyframes shadowPulse {
200
  33% {
201
  box-shadow:
 
1
+ <!-- videoslider/frontend/shared/Video.svelte -->
 
 
 
 
 
 
2
  <svelte:options accessors={true} />
3
 
4
  <script lang="ts">
5
+ // Svelte and Gradio imports
6
  import type { HTMLVideoAttributes } from "svelte/elements";
7
  import { createEventDispatcher } from "svelte";
8
  import { loaded } from "./utils";
 
9
  import { resolve_wasm_src } from "@gradio/wasm/svelte";
 
10
  import Hls from "hls.js";
11
 
12
  // ------------------
13
  // Props
14
  // ------------------
 
 
15
  export let src: HTMLVideoAttributes["src"] = undefined;
16
  export let muted: HTMLVideoAttributes["muted"] = undefined;
17
  export let playsinline: HTMLVideoAttributes["playsinline"] = undefined;
 
19
  export let autoplay: HTMLVideoAttributes["autoplay"] = undefined;
20
  export let controls: HTMLVideoAttributes["controls"] = undefined;
21
  export let loop: boolean;
22
+ export let fullscreen = false;
23
+ export let small = false;
 
 
 
 
 
24
  /** A direct reference to the underlying HTML <video> element. */
25
  export let node: HTMLVideoElement | undefined = undefined;
26
  /** If true, the source is an HLS stream and will be handled by hls.js. */
27
+ export let is_stream: boolean | undefined;
28
  /** If true, displays a loading overlay on the video. */
29
  export let processingVideo = false;
30
+ /** The current playback time of the video, in seconds. */
31
+ export let currentTime: number | undefined = undefined;
32
+ /** The total duration of the video, in seconds. */
33
+ export let duration: number | undefined = undefined;
34
+ /** The paused state of the video. */
35
+ export let paused: boolean | undefined = undefined;
36
 
37
  // -----------------
38
  // Internal State
39
  // -----------------
40
  let resolved_src: typeof src;
41
  let stream_active = false;
 
 
 
 
42
  let latest_src: typeof src;
43
+
44
+ /** This block handles resolving video sources in a WebAssembly (Wasm) environment. */
45
  $: {
46
  resolved_src = src;
47
  latest_src = src;
 
57
 
58
  /**
59
  * Initializes and attaches an HLS.js player to the video element for streaming.
 
60
  * @param src The URL of the HLS manifest (.m3u8 file).
61
  * @param is_stream A flag to enable or disable this functionality.
62
  * @param node The HTML video element to attach the stream to.
63
  */
64
+ function load_stream(src: string | null | undefined, is_stream: boolean, node: HTMLVideoElement): void {
 
 
 
 
65
  if (!src || !is_stream) return;
 
66
  if (Hls.isSupported() && !stream_active) {
67
  const hls = new Hls({
 
68
  maxBufferLength: 1,
69
  maxMaxBufferLength: 1,
70
  lowLatencyMode: true
71
  });
72
  hls.loadSource(src);
73
  hls.attachMedia(node);
74
+ hls.on(Hls.Events.MANIFEST_PARSED, () => node.play());
75
+ hls.on(Hls.Events.ERROR, (event, data) => {
 
 
76
  if (data.fatal) {
77
  switch (data.type) {
78
  case Hls.ErrorTypes.NETWORK_ERROR:
 
93
 
94
  /** Reset the HLS stream when the video source changes. */
95
  $: src, (stream_active = false);
 
96
  /** Trigger the HLS stream loader if the source is a stream. */
97
  $: if (node && src && is_stream) {
98
  load_stream(src, is_stream, node);
99
  }
100
  </script>
101
 
 
 
 
 
 
 
102
  <div class:hidden={!processingVideo} class="overlay">
103
  <span class="load-wrap">
104
  <span class="loader" />
 
112
  {autoplay}
113
  {controls}
114
  {loop}
115
+ class:fullscreen
116
+ class:small
117
  on:loadeddata={dispatch.bind(null, "loadeddata")}
118
  on:click={dispatch.bind(null, "click")}
119
  on:play={dispatch.bind(null, "play")}
120
  on:pause={dispatch.bind(null, "pause")}
121
  on:ended={dispatch.bind(null, "ended")}
122
  on:error={dispatch.bind(null, "error", "Video not playable")}
 
 
123
  bind:currentTime
124
  bind:duration
125
  bind:paused
126
  bind:this={node}
 
 
127
  use:loaded={{ autoplay: autoplay ?? false }}
 
128
  data-testid={$$props["data-testid"]}
129
  crossorigin="anonymous"
130
  >
 
138
  width: 100%;
139
  height: 100%;
140
  }
 
141
  .hidden {
142
  display: none;
143
  }
 
144
  .load-wrap {
145
  display: flex;
146
  justify-content: center;
147
  align-items: center;
148
  height: 100%;
149
  }
 
150
  .loader {
151
  display: flex;
152
  position: relative;
 
161
  height: 10px;
162
  scale: 0.5;
163
  }
 
164
  @keyframes shadowPulse {
165
  33% {
166
  box-shadow:
src/frontend/shared/VideoSliderPreview.svelte CHANGED
@@ -1,235 +1,375 @@
1
- <!--
2
- @component
3
- This component provides the display-only view for the VideoSlider. It shows two
4
- videos side-by-side, with a draggable slider to reveal one over the other. It
5
- also handles synchronized play/pause functionality for both videos.
6
- -->
7
- <script lang="ts">
8
- import { createEventDispatcher } from "svelte";
9
- import type { FileData, Client } from "@gradio/client";
10
- import type { I18nFormatter } from "@gradio/utils";
11
-
12
- import Slider from "./Slider.svelte";
13
- import Player from "./Player.svelte";
14
-
15
- import { BlockLabel, Empty, IconButton, IconButtonWrapper, FullscreenButton } from "@gradio/atoms";
16
- import { Video as VideoIcon, Download, Clear } from "@gradio/icons";
17
- import { DownloadLink } from "@gradio/wasm/svelte";
18
-
19
- // ------------------
20
- // Props
21
- // ------------------
22
- export let value: [FileData | null, FileData | null] = [null, null];
23
- export let label: string | undefined = undefined;
24
- export let show_download_button = true;
25
- export let show_label: boolean;
26
- export let i18n: I18nFormatter;
27
- /** The normalized (0-1) position of the slider. */
28
- export let position: number;
29
- export let slider_color: string;
30
- export let show_fullscreen_button = true;
31
- export let fullscreen = false;
32
- export let interactive = true;
33
- export let autoplay = false;
34
- export let loop = false;
35
- export let upload: Client["upload"];
36
-
37
- const dispatch = createEventDispatcher<{ clear: void }>();
38
-
39
- // -----------------
40
- // Internal State
41
- // -----------------
42
- /** A direct reference to the master HTML <video> element. */
43
- let video1_el: HTMLVideoElement;
44
- /** A direct reference to the slave HTML <video> element. */
45
- let video2_el: HTMLVideoElement;
46
-
47
- /** A flag that becomes true once the master video has loaded its data. */
48
- let video_is_ready = false;
49
- /** A flag to ensure the initial autoplay logic runs only once. */
50
- let initial_autoplay_done = false;
51
-
52
- /** A reactive CSS style to create the "reveal" effect based on the slider's position. */
53
- $: style = `clip-path: inset(0 0 0 ${position * 100}%)`;
54
-
55
- // -----------------
56
- // Event Handlers
57
- // -----------------
58
-
59
- /** Sets a flag to true when the master video signals it's ready to play. */
60
- function handle_video_ready(): void {
61
- video_is_ready = true;
62
- }
63
-
64
- /** Toggles play/pause for both videos simultaneously when the user interacts. */
65
- function toggle_playback(): void {
66
- if (!video1_el || !video2_el) {
67
- return;
68
- }
69
-
70
- const is_paused = video1_el.paused;
71
-
72
- if (is_paused) {
73
- video1_el.play().catch(() => {});
74
- video2_el.play().catch(() => {});
75
- } else {
76
- video1_el.pause();
77
- video2_el.pause();
78
- }
79
- }
80
-
81
- // -----------------
82
- // Reactive Logic
83
- // -----------------
84
- $: {
85
- // This block handles initial autoplay once all conditions are met, preventing race conditions.
86
- if (video1_el && video_is_ready && autoplay && !initial_autoplay_done) {
87
- video1_el.play().catch(() => {});
88
- initial_autoplay_done = true;
89
- }
90
-
91
- // This block continuously synchronizes the slave video to the master video.
92
- if (video1_el && video2_el) {
93
- // Sync playback time.
94
- if (Math.abs(video1_el.currentTime - video2_el.currentTime) > 0.1) {
95
- video2_el.currentTime = video1_el.currentTime;
96
- }
97
-
98
- // Sync play/pause state.
99
- if (video1_el.paused !== video2_el.paused) {
100
- if (video1_el.paused) {
101
- video2_el.pause();
102
- } else {
103
- video2_el.play().catch(() => {});
104
- }
105
- }
106
- }
107
- }
108
- </script>
109
-
110
- <BlockLabel {show_label} Icon={VideoIcon} label={label || "Video Slider"} />
111
-
112
- <!-- Show an empty state if no videos are provided. -->
113
- {#if value === null || value[0] === null || value[1] === null}
114
- <Empty unpadded_box={true} size="large"><VideoIcon /></Empty>
115
- {:else}
116
- <div class="video-container">
117
- <IconButtonWrapper>
118
- {#if show_fullscreen_button}
119
- <FullscreenButton {fullscreen} on:fullscreen />
120
- {/if}
121
-
122
- {#if show_download_button && value[1]}
123
- <DownloadLink href={value[1].meta?._base64 || value[1].url} download={value[1].orig_name || "video"}>
124
- <IconButton Icon={Download} label={i18n("common.download")} />
125
- </DownloadLink>
126
- {/if}
127
-
128
- {#if interactive}
129
- <IconButton
130
- Icon={Clear}
131
- label="Remove Videos"
132
- on:click={(event) => {
133
- value = [null, null];
134
- dispatch("clear");
135
- event.stopPropagation();
136
- }}
137
- />
138
- {/if}
139
- </IconButtonWrapper>
140
-
141
- <!-- This main wrapper handles clicks for the entire area and makes it accessible. -->
142
- <div
143
- class="main-wrapper"
144
- on:click={toggle_playback}
145
- on:keydown={(event) => {
146
- if (event.key === 'Enter' || event.key === ' ') {
147
- toggle_playback();
148
- }
149
- }}
150
- role="button"
151
- tabindex="0"
152
- >
153
- <!-- The first (bottom) video player. -->
154
- <div class="player-wrapper">
155
- {#if value[0]}
156
- <Player
157
- src={value[0].meta?._base64 || value[0].url}
158
- bind:video_el={video1_el}
159
- on:loadeddata={handle_video_ready}
160
- {loop}
161
- muted={true}
162
- {i18n}
163
- {upload}
164
- mirror={false}
165
- is_stream={value[0].is_stream}
166
- interactive={false}
167
- />
168
- {/if}
169
- </div>
170
-
171
- <!-- The second (top, clipped) video player. -->
172
- <div class="player-wrapper fixed" {style}>
173
- {#if value[1]}
174
- <Player
175
- src={value[1].meta?._base64 || value[1].url}
176
- bind:video_el={video2_el}
177
- {loop}
178
- muted={true}
179
- {i18n}
180
- {upload}
181
- mirror={false}
182
- is_stream={value[1].is_stream}
183
- interactive={false}
184
- />
185
- {/if}
186
- </div>
187
-
188
- <!-- The slider is an overlay for dragging, but does not handle clicks. -->
189
- <Slider
190
- bind:position
191
- {slider_color}
192
- />
193
- </div>
194
- </div>
195
- {/if}
196
-
197
- <style>
198
- .video-container {
199
- height: 100%;
200
- width: 100%;
201
- position: relative;
202
- display: flex;
203
- align-items: center;
204
- justify-content: center;
205
- overflow: hidden;
206
- }
207
-
208
- .main-wrapper {
209
- position: relative;
210
- width: 100%;
211
- height: 100%;
212
- cursor: pointer;
213
- }
214
-
215
- .player-wrapper {
216
- position: absolute;
217
- top: 0;
218
- left: 0;
219
- width: 100%;
220
- height: 100%;
221
- }
222
-
223
- .player-wrapper.fixed {
224
- background: var(--block-background-fill);
225
- }
226
-
227
- /* Ensure the Slider component is layered on top of the video players. */
228
- :global(.main-wrapper > .wrap) {
229
- position: absolute;
230
- top: 0;
231
- left: 0;
232
- z-index: 10;
233
- cursor: default;
234
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  </style>
 
1
+ <!-- videoslider/frontend/shared/VideoSliderPreview.svelte -->
2
+ <script lang="ts">
3
+ // Svelte and Gradio imports
4
+ import { createEventDispatcher, onMount, onDestroy, tick } from "svelte";
5
+ import type { FileData, Client } from "@gradio/client";
6
+ import type { I18nFormatter } from "@gradio/utils";
7
+ import Slider from "./Slider.svelte";
8
+ import Player from "./Player.svelte";
9
+ import { BlockLabel, Empty, IconButton, IconButtonWrapper, FullscreenButton } from "@gradio/atoms";
10
+ import { Video as VideoIcon, Download, Clear, VolumeMuted, VolumeHigh } from "@gradio/icons";
11
+ import { DownloadLink } from "@gradio/wasm/svelte";
12
+
13
+ // ------------------
14
+ // Props
15
+ // ------------------
16
+ export let value: [FileData | null, FileData | null] = [null, null];
17
+ export let label: string | undefined = undefined;
18
+ export let show_download_button = true;
19
+ export let show_label: boolean;
20
+ export let i18n: I18nFormatter;
21
+ export let position: number = 0.5;
22
+ export let slider_color: string;
23
+ export let show_fullscreen_button = true;
24
+ export let show_mute_button = true;
25
+ export let fullscreen = false;
26
+ export let interactive = true;
27
+ export let autoplay = false;
28
+ export let loop = false;
29
+ export let upload: Client["upload"];
30
+
31
+ const dispatch = createEventDispatcher<{
32
+ clear: void;
33
+ fullscreen: boolean;
34
+ load: { top: number; left: number; width: number; height: number };
35
+ }>();
36
+
37
+ // -----------------
38
+ // Internal State & Element References
39
+ // -----------------
40
+ let video1_el: HTMLVideoElement | undefined;
41
+ let video2_el: HTMLVideoElement | undefined;
42
+ let main_wrapper_el: HTMLDivElement | undefined;
43
+ let image_size = { top: 0, left: 0, width: 0, height: 0 };
44
+ let viewport_width = 0;
45
+ let resizeObserver: ResizeObserver | undefined;
46
+ /** Tracks the muted state for both videos. Starts true to allow autoplay. */
47
+ let isMuted = true;
48
+ /** A flag to prevent the main click handler from firing when interacting with overlay buttons. */
49
+ let isInteractingWithButtons = false;
50
+ /** A flag to ensure the dimension initialization logic runs only once. */
51
+ let is_initialized = false;
52
+
53
+ // -----------------
54
+ // Event Handlers
55
+ // -----------------
56
+
57
+ /** Toggles the muted state for both videos. */
58
+ function toggleMute(event: Event) {
59
+ event.stopPropagation();
60
+ isInteractingWithButtons = true;
61
+ if (video1_el && video2_el) {
62
+ isMuted = !isMuted;
63
+ video1_el.muted = isMuted;
64
+ video2_el.muted = isMuted;
65
+ }
66
+ setTimeout(() => (isInteractingWithButtons = false), 0);
67
+ }
68
+
69
+ /** Clears both videos and resets the slider position. */
70
+ function removeVideos(event: Event) {
71
+ event.stopPropagation();
72
+ isInteractingWithButtons = true;
73
+ value = [null, null];
74
+ position = 0.5;
75
+ dispatch("clear");
76
+ setTimeout(() => (isInteractingWithButtons = false), 0);
77
+ }
78
+
79
+ /** Toggles play/pause for both videos simultaneously. */
80
+ function toggle_playback(event: Event): void {
81
+ event.stopPropagation();
82
+ if (!video1_el || !video2_el) return;
83
+ const is_paused = video1_el.paused;
84
+ if (is_paused) {
85
+ video1_el.play().catch(() => {});
86
+ video2_el.play().catch(() => {});
87
+ } else {
88
+ video1_el.pause();
89
+ video2_el.pause();
90
+ }
91
+ }
92
+
93
+ /** Handles the fullscreen event from the button, resets position, and dispatches. */
94
+ function handle_fullscreen_toggle(event: CustomEvent<boolean>) {
95
+ position = 0.5; // We still want to reset the position
96
+ dispatch("fullscreen", event.detail);
97
+ }
98
+
99
+ /** Handles the load event from the Player component to update dimensions. */
100
+ function handle_video_load(event: CustomEvent) {
101
+ image_size = event.detail;
102
+ if (main_wrapper_el) {
103
+ viewport_width = main_wrapper_el.getBoundingClientRect().width;
104
+ }
105
+ position = 0.5;
106
+ dispatch("load", image_size);
107
+ }
108
+
109
+ /** A utility function to constrain a value within a minimum and maximum range. */
110
+ function clamp(value: number, min: number, max: number): number {
111
+ return Math.min(Math.max(value, min), max);
112
+ }
113
+
114
+ /**
115
+ * Calculates the clipped position of the slider based on the video's
116
+ * dimensions and offset within the viewport.
117
+ */
118
+ function get_coords_at_viewport(
119
+ viewport_percent_x: number,
120
+ viewportWidth: number,
121
+ video_width: number,
122
+ video_offset_x: number
123
+ ): number {
124
+ const px_relative_to_video = viewport_percent_x * video_width;
125
+ const pixel_position = px_relative_to_video + video_offset_x;
126
+ const percent_position = pixel_position / viewportWidth;
127
+ return clamp(percent_position, 0, 1);
128
+ }
129
+
130
+ /**
131
+ * Sets up a ResizeObserver to monitor the video and its container,
132
+ * updating the `image_size` state whenever their dimensions change.
133
+ */
134
+ function init_video(video: HTMLVideoElement | null, wrapper: HTMLDivElement | null): void {
135
+ if (!video || !wrapper) return;
136
+
137
+ resizeObserver?.disconnect();
138
+ const updateVideoDimensions = () => {
139
+ const rect = video.getBoundingClientRect();
140
+ const wrapperRect = wrapper.getBoundingClientRect();
141
+ image_size = {
142
+ top: rect.top - wrapperRect.top,
143
+ left: rect.left - wrapperRect.left,
144
+ width: rect.width || wrapperRect.width,
145
+ height: rect.height || wrapperRect.height
146
+ };
147
+ viewport_width = wrapperRect.width;
148
+ dispatch("load", image_size);
149
+ };
150
+
151
+ resizeObserver = new ResizeObserver(() => {
152
+ updateVideoDimensions();
153
+ position = 0.5;
154
+ });
155
+
156
+ video.addEventListener('loadedmetadata', updateVideoDimensions);
157
+ resizeObserver.observe(wrapper);
158
+ resizeObserver.observe(video);
159
+ updateVideoDimensions();
160
+ }
161
+
162
+ // -----------------
163
+ // Reactive Logic & Lifecycle
164
+ // -----------------
165
+
166
+ /** Calculates the coordinates for the CSS clip-path. */
167
+ $: coords_at_viewport = get_coords_at_viewport(
168
+ position,
169
+ viewport_width || 640,
170
+ image_size.width || viewport_width || 640,
171
+ image_size.left || 0
172
+ );
173
+ /** A reactive CSS style to create the "reveal" effect. */
174
+ $: style = `clip-path: inset(0 0 0 ${coords_at_viewport * 100}%)`;
175
+
176
+ /** Initializes the video dimension tracking once the necessary elements are available. */
177
+ $: if (main_wrapper_el && video1_el && !is_initialized) {
178
+ init_video(video1_el, main_wrapper_el);
179
+ is_initialized = true;
180
+ }
181
+
182
+ /** Synchronizes the state of the two videos (playback time and pause state). */
183
+ $: {
184
+ if (video1_el && autoplay && !video1_el.played.length) {
185
+ video1_el.play().catch(() => {});
186
+ }
187
+ if (video1_el && video2_el) {
188
+ if (Math.abs(video1_el.currentTime - video2_el.currentTime) > 0.1) {
189
+ video2_el.currentTime = video1_el.currentTime;
190
+ }
191
+ if (video1_el.paused !== video2_el.paused) {
192
+ if (video1_el.paused) {
193
+ video2_el.pause();
194
+ } else {
195
+ video2_el.play().catch(() => {});
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ /** On mount, sets initial state and cleans up the observer on destroy. */
202
+ onMount(() => {
203
+ position = 0.5;
204
+ if (video1_el) video1_el.muted = true;
205
+ if (video2_el) video2_el.muted = true;
206
+ return () => {
207
+ resizeObserver?.disconnect();
208
+ };
209
+ });
210
+ </script>
211
+
212
+ <BlockLabel {show_label} Icon={VideoIcon} label={label || "Video Slider"} />
213
+
214
+ {#if value === null || value[0] === null || value[1] === null}
215
+ <Empty unpadded_box={true} size="large"><VideoIcon /></Empty>
216
+ {:else}
217
+ <div class="video-container">
218
+ <div
219
+ class="icon-button-wrapper"
220
+ role="group"
221
+ >
222
+ <IconButtonWrapper>
223
+ {#if show_fullscreen_button}
224
+ <FullscreenButton
225
+ bind:fullscreen
226
+ on:fullscreen={handle_fullscreen_toggle}
227
+ />
228
+ {/if}
229
+ {#if show_download_button && value[1]}
230
+ <DownloadLink href={value[1]?.url} download={value[1]?.orig_name || "video"}>
231
+ <IconButton Icon={Download} label={i18n("common.download")} />
232
+ </DownloadLink>
233
+ {/if}
234
+ {#if show_mute_button}
235
+ <div role="button" tabindex="0" on:mousedown|stopPropagation on:touchstart|stopPropagation>
236
+ <IconButton
237
+ Icon={isMuted ? VolumeMuted : VolumeHigh}
238
+ label={isMuted ? i18n("common.unmute") : i18n("common.mute")}
239
+ on:click={toggleMute}
240
+ on:keydown={(event) => {
241
+ if (event.key === "Enter" || event.key === " ") {
242
+ toggleMute(event);
243
+ }
244
+ }}
245
+ />
246
+ </div>
247
+ {/if}
248
+ {#if interactive}
249
+ <div role="button" tabindex="0" on:mousedown|stopPropagation on:touchstart|stopPropagation>
250
+ <IconButton
251
+ Icon={Clear}
252
+ label="Remove Videos"
253
+ on:click={removeVideos}
254
+ on:keydown={(event) => {
255
+ if (event.key === "Enter" || event.key === " ") {
256
+ removeVideos(event);
257
+ }
258
+ }}
259
+ />
260
+ </div>
261
+ {/if}
262
+ </IconButtonWrapper>
263
+ </div>
264
+
265
+ <div
266
+ class="main-wrapper"
267
+ bind:this={main_wrapper_el}
268
+ on:mousedown|stopPropagation={toggle_playback}
269
+ on:touchstart|stopPropagation={toggle_playback}
270
+ on:keydown={(event) => {
271
+ if (event.key === "Enter" || event.key === " ") {
272
+ toggle_playback(event);
273
+ }
274
+ }}
275
+ role="button"
276
+ tabindex="0"
277
+ >
278
+ <Slider bind:position {slider_color} {image_size} disabled={isInteractingWithButtons} bind:parent_el={main_wrapper_el}>
279
+ <div class="player-wrapper">
280
+ {#if value[0]}
281
+ <Player
282
+ src={value[0].meta?._base64 || value[0].url}
283
+ bind:video_el={video1_el}
284
+ on:load={handle_video_load}
285
+ {loop}
286
+ muted={isMuted}
287
+ {i18n}
288
+ {upload}
289
+ mirror={false}
290
+ is_stream={value[0].is_stream}
291
+ interactive={false}
292
+ {fullscreen}
293
+ />
294
+ {/if}
295
+ </div>
296
+ <div class="player-wrapper fixed" style={style}>
297
+ {#if value[1]}
298
+ <Player
299
+ src={value[1].meta?._base64 || value[1].url}
300
+ bind:video_el={video2_el}
301
+ {loop}
302
+ muted={isMuted}
303
+ {i18n}
304
+ {upload}
305
+ mirror={false}
306
+ is_stream={value[1].is_stream}
307
+ interactive={false}
308
+ {fullscreen}
309
+ />
310
+ {/if}
311
+ </div>
312
+ </Slider>
313
+ </div>
314
+ </div>
315
+ {/if}
316
+
317
+ <style>
318
+ .video-container {
319
+ height: 100%;
320
+ width: 100%;
321
+ position: relative;
322
+ display: flex;
323
+ align-items: center;
324
+ justify-content: center;
325
+ overflow: hidden;
326
+ z-index: 1;
327
+ }
328
+ .main-wrapper {
329
+ user-select: none;
330
+ height: 100%;
331
+ width: 100%;
332
+ position: relative;
333
+ display: flex;
334
+ align-items: center;
335
+ justify-content: center;
336
+ z-index: 2;
337
+ }
338
+ .player-wrapper {
339
+ position: absolute;
340
+ top: 0;
341
+ left: 0;
342
+ width: 100%;
343
+ height: 100%;
344
+ display: flex;
345
+ align-items: center;
346
+ justify-content: center;
347
+ z-index: 3;
348
+ pointer-events: none;
349
+ }
350
+ .player-wrapper.fixed {
351
+ background: var(--block-background-fill);
352
+ z-index: 4;
353
+ }
354
+ .player-wrapper :global(video) {
355
+ width: 100%;
356
+ height: 100%;
357
+ object-fit: contain;
358
+ z-index: 5;
359
+ pointer-events: none;
360
+ }
361
+ :global(.main-wrapper > .wrap) {
362
+ position: absolute;
363
+ top: 0;
364
+ left: 0;
365
+ z-index: 10;
366
+ cursor: default;
367
+ }
368
+ .icon-button-wrapper {
369
+ z-index: 1001; /* Above slider's z-index: 1000 */
370
+ pointer-events: auto;
371
+ position: absolute;
372
+ top: 10px;
373
+ right: 10px;
374
+ }
375
  </style>
src/pyproject.toml CHANGED
@@ -8,12 +8,12 @@ build-backend = "hatchling.build"
8
 
9
  [project]
10
  name = "gradio_videoslider"
11
- version = "0.0.1"
12
  description = "VideoSlider Component for Gradio"
13
  readme = "README.md"
14
  license = "apache-2.0"
15
  requires-python = ">=3.10"
16
- authors = [{ name = "YOUR NAME", email = "YOUREMAIL@domain.com" }]
17
  keywords = ["gradio-custom-component", "gradio-template-ImageSlider"]
18
  # Add dependencies here
19
  dependencies = ["gradio>=4.0,<6.0"]
 
8
 
9
  [project]
10
  name = "gradio_videoslider"
11
+ version = "0.0.2"
12
  description = "VideoSlider Component for Gradio"
13
  readme = "README.md"
14
  license = "apache-2.0"
15
  requires-python = ">=3.10"
16
+ authors = [{ name = "Eliseu Silva", email = "elismasilva@gmail.com" }]
17
  keywords = ["gradio-custom-component", "gradio-template-ImageSlider"]
18
  # Add dependencies here
19
  dependencies = ["gradio>=4.0,<6.0"]