victor HF Staff commited on
Commit
40461ff
·
1 Parent(s): dc096a8

feat: Add MediaGallery custom component for mixed media assets

Browse files

- Custom Gradio component supporting images, videos, and audio in unified grid
- Responsive grid layout (2-5 columns based on screen size)
- Square thumbnails with media type badges
- Add Media button for uploading additional files
- Audio preview with gradient background
- File upload handling with proper media type detection
- Examples display up to 5 previews without hover effects

app.py CHANGED
@@ -1,5 +1,6 @@
1
  import gradio as gr
2
  import spaces
 
3
 
4
  from PIL import Image
5
  from moviepy.editor import VideoFileClip, AudioFileClip
@@ -56,7 +57,37 @@ allowed_medias = [
56
  ]
57
 
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  def get_files_infos(files):
 
60
  results = []
61
  for file in files:
62
  file_path = Path(file.name)
@@ -236,19 +267,22 @@ Remember: Simpler is better. Only use advanced ffmpeg features if absolutely nec
236
  else:
237
  # Use existing conversation history
238
  messages = conversation_history[:]
239
-
240
  # If there's a previous error, add it as a separate message exchange
241
  if previous_error and previous_command:
242
  # Add the failed command as assistant response
243
- messages.append({
244
- "role": "assistant",
245
- "content": f"I'll execute this FFmpeg command:\n\n```bash\n{previous_command}\n```"
246
- })
247
-
 
 
248
  # Add the error as user feedback
249
- messages.append({
250
- "role": "user",
251
- "content": f"""The command failed with the following error:
 
252
 
253
  ERROR MESSAGE:
254
  {previous_error}
@@ -266,14 +300,17 @@ FORMAT DETECTION KEYWORDS:
266
  - "horizontal", "landscape", "16:9", "YouTube", "TV" → Use 1920x1080 (default)
267
  - "square", "1:1", "Instagram post" → Use 1080x1080
268
 
269
- Please provide a corrected FFmpeg command."""
270
- })
 
271
  else:
272
  # Add new user request to existing conversation
273
- messages.append({
274
- "role": "user",
275
- "content": user_content,
276
- })
 
 
277
  try:
278
  # Print the complete prompt
279
  print("\n=== COMPLETE PROMPT ===")
@@ -299,18 +336,19 @@ Please provide a corrected FFmpeg command."""
299
  )
300
  content = completion.choices[0].message.content
301
  print(f"\n=== RAW API RESPONSE ===\n{content}\n========================\n")
302
-
303
  # Extract command from code block if present
304
  import re
 
305
  command = None
306
-
307
  # Try multiple code block patterns
308
  code_patterns = [
309
  r"```(?:bash|sh|shell)?\n(.*?)\n```", # Standard code blocks
310
  r"```\n(.*?)\n```", # Plain code blocks
311
  r"`([^`]*ffmpeg[^`]*)`", # Inline code with ffmpeg
312
  ]
313
-
314
  for pattern in code_patterns:
315
  matches = re.findall(pattern, content, re.DOTALL | re.IGNORECASE)
316
  for match in matches:
@@ -319,7 +357,7 @@ Please provide a corrected FFmpeg command."""
319
  break
320
  if command:
321
  break
322
-
323
  # If no code block found, try to find ffmpeg lines directly
324
  if not command:
325
  ffmpeg_lines = [
@@ -329,7 +367,7 @@ Please provide a corrected FFmpeg command."""
329
  ]
330
  if ffmpeg_lines:
331
  command = ffmpeg_lines[0]
332
-
333
  # Last resort: look for any line containing ffmpeg
334
  if not command:
335
  for line in content.split("\n"):
@@ -337,21 +375,18 @@ Please provide a corrected FFmpeg command."""
337
  if "ffmpeg" in line.lower() and len(line) > 10:
338
  command = line
339
  break
340
-
341
  if not command:
342
  print(f"ERROR: No ffmpeg command found in response")
343
  command = content.replace("\n", " ").strip()
344
-
345
  print(f"=== EXTRACTED COMMAND ===\n{command}\n========================\n")
346
-
347
  # remove output.mp4 with the actual output file path
348
  command = command.replace("output.mp4", "")
349
 
350
  # Add the assistant's response to conversation history
351
- messages.append({
352
- "role": "assistant",
353
- "content": content
354
- })
355
 
356
  return command, messages
357
  except Exception as e:
@@ -377,8 +412,8 @@ def compose_video(
377
  """
378
  Compose videos from existing media assets using natural language instructions.
379
 
380
- This tool is NOT for AI video generation. Instead, it uses AI to generate FFmpeg
381
- commands that combine, edit, and transform your uploaded images, videos, and audio
382
  files based on natural language descriptions.
383
 
384
  Args:
@@ -407,6 +442,8 @@ def update(
407
  if prompt == "":
408
  raise gr.Error("Please enter a prompt.")
409
 
 
 
410
  files_info = get_files_infos(files)
411
  # disable this if you're running the app locally or on your own server
412
  for file_info in files_info:
@@ -564,10 +601,11 @@ with gr.Blocks() as demo:
564
  )
565
  with gr.Row():
566
  with gr.Column():
567
- user_files = gr.File(
568
- file_count="multiple",
569
- label="Media files",
570
  file_types=allowed_medias,
 
 
 
571
  )
572
  user_prompt = gr.Textbox(
573
  placeholder="eg: Remove the 3 first seconds of the video",
 
1
  import gradio as gr
2
  import spaces
3
+ from gradio_mediagallery import MediaGallery
4
 
5
  from PIL import Image
6
  from moviepy.editor import VideoFileClip, AudioFileClip
 
57
  ]
58
 
59
 
60
+ class FileWrapper:
61
+ """Wrapper to provide .name attribute for MediaGallery output tuples."""
62
+
63
+ def __init__(self, path):
64
+ self.name = path if isinstance(path, str) else str(path)
65
+
66
+
67
+ def normalize_files(files):
68
+ """Convert MediaGallery output or gr.File output to list of file-like objects."""
69
+ if not files:
70
+ return []
71
+
72
+ result = []
73
+ for item in files:
74
+ if isinstance(item, tuple):
75
+ # MediaGallery returns (path, caption) tuples
76
+ path = item[0]
77
+ result.append(FileWrapper(path))
78
+ elif hasattr(item, "name"):
79
+ # gr.File returns objects with .name attribute
80
+ result.append(item)
81
+ elif isinstance(item, str):
82
+ # Direct file path
83
+ result.append(FileWrapper(item))
84
+ else:
85
+ result.append(FileWrapper(str(item)))
86
+ return result
87
+
88
+
89
  def get_files_infos(files):
90
+ files = normalize_files(files)
91
  results = []
92
  for file in files:
93
  file_path = Path(file.name)
 
267
  else:
268
  # Use existing conversation history
269
  messages = conversation_history[:]
270
+
271
  # If there's a previous error, add it as a separate message exchange
272
  if previous_error and previous_command:
273
  # Add the failed command as assistant response
274
+ messages.append(
275
+ {
276
+ "role": "assistant",
277
+ "content": f"I'll execute this FFmpeg command:\n\n```bash\n{previous_command}\n```",
278
+ }
279
+ )
280
+
281
  # Add the error as user feedback
282
+ messages.append(
283
+ {
284
+ "role": "user",
285
+ "content": f"""The command failed with the following error:
286
 
287
  ERROR MESSAGE:
288
  {previous_error}
 
300
  - "horizontal", "landscape", "16:9", "YouTube", "TV" → Use 1920x1080 (default)
301
  - "square", "1:1", "Instagram post" → Use 1080x1080
302
 
303
+ Please provide a corrected FFmpeg command.""",
304
+ }
305
+ )
306
  else:
307
  # Add new user request to existing conversation
308
+ messages.append(
309
+ {
310
+ "role": "user",
311
+ "content": user_content,
312
+ }
313
+ )
314
  try:
315
  # Print the complete prompt
316
  print("\n=== COMPLETE PROMPT ===")
 
336
  )
337
  content = completion.choices[0].message.content
338
  print(f"\n=== RAW API RESPONSE ===\n{content}\n========================\n")
339
+
340
  # Extract command from code block if present
341
  import re
342
+
343
  command = None
344
+
345
  # Try multiple code block patterns
346
  code_patterns = [
347
  r"```(?:bash|sh|shell)?\n(.*?)\n```", # Standard code blocks
348
  r"```\n(.*?)\n```", # Plain code blocks
349
  r"`([^`]*ffmpeg[^`]*)`", # Inline code with ffmpeg
350
  ]
351
+
352
  for pattern in code_patterns:
353
  matches = re.findall(pattern, content, re.DOTALL | re.IGNORECASE)
354
  for match in matches:
 
357
  break
358
  if command:
359
  break
360
+
361
  # If no code block found, try to find ffmpeg lines directly
362
  if not command:
363
  ffmpeg_lines = [
 
367
  ]
368
  if ffmpeg_lines:
369
  command = ffmpeg_lines[0]
370
+
371
  # Last resort: look for any line containing ffmpeg
372
  if not command:
373
  for line in content.split("\n"):
 
375
  if "ffmpeg" in line.lower() and len(line) > 10:
376
  command = line
377
  break
378
+
379
  if not command:
380
  print(f"ERROR: No ffmpeg command found in response")
381
  command = content.replace("\n", " ").strip()
382
+
383
  print(f"=== EXTRACTED COMMAND ===\n{command}\n========================\n")
384
+
385
  # remove output.mp4 with the actual output file path
386
  command = command.replace("output.mp4", "")
387
 
388
  # Add the assistant's response to conversation history
389
+ messages.append({"role": "assistant", "content": content})
 
 
 
390
 
391
  return command, messages
392
  except Exception as e:
 
412
  """
413
  Compose videos from existing media assets using natural language instructions.
414
 
415
+ This tool is NOT for AI video generation. Instead, it uses AI to generate FFmpeg
416
+ commands that combine, edit, and transform your uploaded images, videos, and audio
417
  files based on natural language descriptions.
418
 
419
  Args:
 
442
  if prompt == "":
443
  raise gr.Error("Please enter a prompt.")
444
 
445
+ # Normalize files from MediaGallery or gr.File format
446
+ files = normalize_files(files)
447
  files_info = get_files_infos(files)
448
  # disable this if you're running the app locally or on your own server
449
  for file_info in files_info:
 
601
  )
602
  with gr.Row():
603
  with gr.Column():
604
+ user_files = MediaGallery(
 
 
605
  file_types=allowed_medias,
606
+ label="Media Assets",
607
+ columns=3,
608
+ interactive=True,
609
  )
610
  user_prompt = gr.Textbox(
611
  placeholder="eg: Remove the 3 first seconds of the video",
mediagallery/.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .eggs/
2
+ dist/
3
+ *.pyc
4
+ __pycache__/
5
+ *.py[cod]
6
+ *$py.class
7
+ __tmp/*
8
+ *.pyi
9
+ .mypycache
10
+ .ruff_cache
11
+ node_modules
12
+ backend/**/templates/
mediagallery/README.md ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # `gradio_mediagallery`
3
+ <a href="https://pypi.org/project/gradio_mediagallery/" target="_blank"><img alt="PyPI - Version" src="https://img.shields.io/pypi/v/gradio_mediagallery"></a>
4
+
5
+ Python library for easily interacting with trained machine learning models
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install gradio_mediagallery
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+
17
+ import gradio as gr
18
+ from gradio_mediagallery import MediaGallery
19
+
20
+
21
+ example = MediaGallery().example_value()
22
+
23
+ with gr.Blocks() as demo:
24
+ with gr.Row():
25
+ MediaGallery(label="Blank"), # blank component
26
+ MediaGallery(value=example, label="Populated"), # populated component
27
+
28
+
29
+ if __name__ == "__main__":
30
+ demo.launch()
31
+
32
+ ```
33
+
34
+ ## `MediaGallery`
35
+
36
+ ### Initialization
37
+
38
+ <table>
39
+ <thead>
40
+ <tr>
41
+ <th align="left">name</th>
42
+ <th align="left" style="width: 25%;">type</th>
43
+ <th align="left">default</th>
44
+ <th align="left">description</th>
45
+ </tr>
46
+ </thead>
47
+ <tbody>
48
+ <tr>
49
+ <td align="left"><code>value</code></td>
50
+ <td align="left" style="width: 25%;">
51
+
52
+ ```python
53
+ Sequence[
54
+ np.ndarray | PIL.Image.Image | str | Path | tuple
55
+ ]
56
+ | Callable
57
+ | None
58
+ ```
59
+
60
+ </td>
61
+ <td align="left"><code>None</code></td>
62
+ <td align="left">List of images or videos to display in the gallery by default. If a function is provided, the function will be called each time the app loads to set the initial value of this component.</td>
63
+ </tr>
64
+
65
+ <tr>
66
+ <td align="left"><code>format</code></td>
67
+ <td align="left" style="width: 25%;">
68
+
69
+ ```python
70
+ str
71
+ ```
72
+
73
+ </td>
74
+ <td align="left"><code>"webp"</code></td>
75
+ <td align="left">Format to save images before they are returned to the frontend, such as 'jpeg' or 'png'. This parameter only applies to images that are returned from the prediction function as numpy arrays or PIL Images. The format should be supported by the PIL library.</td>
76
+ </tr>
77
+
78
+ <tr>
79
+ <td align="left"><code>file_types</code></td>
80
+ <td align="left" style="width: 25%;">
81
+
82
+ ```python
83
+ list[str] | None
84
+ ```
85
+
86
+ </td>
87
+ <td align="left"><code>None</code></td>
88
+ <td align="left">List of file extensions or types of files to be uploaded (e.g. ['image', '.mp4']), when this is used as an input component. "image" allows only image files to be uploaded, "video" allows only video files to be uploaded, ".mp4" allows only mp4 files to be uploaded, etc. If None, any image and video files types are allowed.</td>
89
+ </tr>
90
+
91
+ <tr>
92
+ <td align="left"><code>label</code></td>
93
+ <td align="left" style="width: 25%;">
94
+
95
+ ```python
96
+ str | I18nData | None
97
+ ```
98
+
99
+ </td>
100
+ <td align="left"><code>None</code></td>
101
+ <td align="left">the label for this component. Appears above the component and is also used as the header if there are a table of examples for this component. If None and used in a `gr.Interface`, the label will be the name of the parameter this component is assigned to.</td>
102
+ </tr>
103
+
104
+ <tr>
105
+ <td align="left"><code>every</code></td>
106
+ <td align="left" style="width: 25%;">
107
+
108
+ ```python
109
+ Timer | float | None
110
+ ```
111
+
112
+ </td>
113
+ <td align="left"><code>None</code></td>
114
+ <td align="left">Continously calls `value` to recalculate it if `value` is a function (has no effect otherwise). Can provide a Timer whose tick resets `value`, or a float that provides the regular interval for the reset Timer.</td>
115
+ </tr>
116
+
117
+ <tr>
118
+ <td align="left"><code>inputs</code></td>
119
+ <td align="left" style="width: 25%;">
120
+
121
+ ```python
122
+ Component | Sequence[Component] | set[Component] | None
123
+ ```
124
+
125
+ </td>
126
+ <td align="left"><code>None</code></td>
127
+ <td align="left">Components that are used as inputs to calculate `value` if `value` is a function (has no effect otherwise). `value` is recalculated any time the inputs change.</td>
128
+ </tr>
129
+
130
+ <tr>
131
+ <td align="left"><code>show_label</code></td>
132
+ <td align="left" style="width: 25%;">
133
+
134
+ ```python
135
+ bool | None
136
+ ```
137
+
138
+ </td>
139
+ <td align="left"><code>None</code></td>
140
+ <td align="left">if True, will display label.</td>
141
+ </tr>
142
+
143
+ <tr>
144
+ <td align="left"><code>container</code></td>
145
+ <td align="left" style="width: 25%;">
146
+
147
+ ```python
148
+ bool
149
+ ```
150
+
151
+ </td>
152
+ <td align="left"><code>True</code></td>
153
+ <td align="left">If True, will place the component in a container - providing some extra padding around the border.</td>
154
+ </tr>
155
+
156
+ <tr>
157
+ <td align="left"><code>scale</code></td>
158
+ <td align="left" style="width: 25%;">
159
+
160
+ ```python
161
+ int | None
162
+ ```
163
+
164
+ </td>
165
+ <td align="left"><code>None</code></td>
166
+ <td align="left">relative size compared to adjacent Components. For example if Components A and B are in a Row, and A has scale=2, and B has scale=1, A will be twice as wide as B. Should be an integer. scale applies in Rows, and to top-level Components in Blocks where fill_height=True.</td>
167
+ </tr>
168
+
169
+ <tr>
170
+ <td align="left"><code>min_width</code></td>
171
+ <td align="left" style="width: 25%;">
172
+
173
+ ```python
174
+ int
175
+ ```
176
+
177
+ </td>
178
+ <td align="left"><code>160</code></td>
179
+ <td align="left">minimum pixel width, will wrap if not sufficient screen space to satisfy this value. If a certain scale value results in this Component being narrower than min_width, the min_width parameter will be respected first.</td>
180
+ </tr>
181
+
182
+ <tr>
183
+ <td align="left"><code>visible</code></td>
184
+ <td align="left" style="width: 25%;">
185
+
186
+ ```python
187
+ bool
188
+ ```
189
+
190
+ </td>
191
+ <td align="left"><code>True</code></td>
192
+ <td align="left">If False, component will be hidden.</td>
193
+ </tr>
194
+
195
+ <tr>
196
+ <td align="left"><code>elem_id</code></td>
197
+ <td align="left" style="width: 25%;">
198
+
199
+ ```python
200
+ str | None
201
+ ```
202
+
203
+ </td>
204
+ <td align="left"><code>None</code></td>
205
+ <td align="left">An optional string that is assigned as the id of this component in the HTML DOM. Can be used for targeting CSS styles.</td>
206
+ </tr>
207
+
208
+ <tr>
209
+ <td align="left"><code>elem_classes</code></td>
210
+ <td align="left" style="width: 25%;">
211
+
212
+ ```python
213
+ list[str] | str | None
214
+ ```
215
+
216
+ </td>
217
+ <td align="left"><code>None</code></td>
218
+ <td align="left">An optional list of strings that are assigned as the classes of this component in the HTML DOM. Can be used for targeting CSS styles.</td>
219
+ </tr>
220
+
221
+ <tr>
222
+ <td align="left"><code>render</code></td>
223
+ <td align="left" style="width: 25%;">
224
+
225
+ ```python
226
+ bool
227
+ ```
228
+
229
+ </td>
230
+ <td align="left"><code>True</code></td>
231
+ <td align="left">If False, component will not render be rendered in the Blocks context. Should be used if the intention is to assign event listeners now but render the component later.</td>
232
+ </tr>
233
+
234
+ <tr>
235
+ <td align="left"><code>key</code></td>
236
+ <td align="left" style="width: 25%;">
237
+
238
+ ```python
239
+ int | str | tuple[int | str, ...] | None
240
+ ```
241
+
242
+ </td>
243
+ <td align="left"><code>None</code></td>
244
+ <td align="left">in a gr.render, Components with the same key across re-renders are treated as the same component, not a new component. Properties set in 'preserved_by_key' are not reset across a re-render.</td>
245
+ </tr>
246
+
247
+ <tr>
248
+ <td align="left"><code>preserved_by_key</code></td>
249
+ <td align="left" style="width: 25%;">
250
+
251
+ ```python
252
+ list[str] | str | None
253
+ ```
254
+
255
+ </td>
256
+ <td align="left"><code>"value"</code></td>
257
+ <td align="left">A list of parameters from this component's constructor. Inside a gr.render() function, if a component is re-rendered with the same key, these (and only these) parameters will be preserved in the UI (if they have been changed by the user or an event listener) instead of re-rendered based on the values provided during constructor.</td>
258
+ </tr>
259
+
260
+ <tr>
261
+ <td align="left"><code>columns</code></td>
262
+ <td align="left" style="width: 25%;">
263
+
264
+ ```python
265
+ int | None
266
+ ```
267
+
268
+ </td>
269
+ <td align="left"><code>2</code></td>
270
+ <td align="left">Represents the number of images that should be shown in one row.</td>
271
+ </tr>
272
+
273
+ <tr>
274
+ <td align="left"><code>rows</code></td>
275
+ <td align="left" style="width: 25%;">
276
+
277
+ ```python
278
+ int | None
279
+ ```
280
+
281
+ </td>
282
+ <td align="left"><code>None</code></td>
283
+ <td align="left">Represents the number of rows in the image grid.</td>
284
+ </tr>
285
+
286
+ <tr>
287
+ <td align="left"><code>height</code></td>
288
+ <td align="left" style="width: 25%;">
289
+
290
+ ```python
291
+ int | float | str | None
292
+ ```
293
+
294
+ </td>
295
+ <td align="left"><code>None</code></td>
296
+ <td align="left">The height of the gallery component, specified in pixels if a number is passed, or in CSS units if a string is passed. If more images are displayed than can fit in the height, a scrollbar will appear.</td>
297
+ </tr>
298
+
299
+ <tr>
300
+ <td align="left"><code>allow_preview</code></td>
301
+ <td align="left" style="width: 25%;">
302
+
303
+ ```python
304
+ bool
305
+ ```
306
+
307
+ </td>
308
+ <td align="left"><code>True</code></td>
309
+ <td align="left">If True, images in the gallery will be enlarged when they are clicked. Default is True.</td>
310
+ </tr>
311
+
312
+ <tr>
313
+ <td align="left"><code>preview</code></td>
314
+ <td align="left" style="width: 25%;">
315
+
316
+ ```python
317
+ bool | None
318
+ ```
319
+
320
+ </td>
321
+ <td align="left"><code>None</code></td>
322
+ <td align="left">If True, MediaGallery will start in preview mode, which shows all of the images as thumbnails and allows the user to click on them to view them in full size. Only works if allow_preview is True.</td>
323
+ </tr>
324
+
325
+ <tr>
326
+ <td align="left"><code>selected_index</code></td>
327
+ <td align="left" style="width: 25%;">
328
+
329
+ ```python
330
+ int | None
331
+ ```
332
+
333
+ </td>
334
+ <td align="left"><code>None</code></td>
335
+ <td align="left">The index of the image that should be initially selected. If None, no image will be selected at start. If provided, will set MediaGallery to preview mode unless allow_preview is set to False.</td>
336
+ </tr>
337
+
338
+ <tr>
339
+ <td align="left"><code>object_fit</code></td>
340
+ <td align="left" style="width: 25%;">
341
+
342
+ ```python
343
+ Literal[
344
+ "contain", "cover", "fill", "none", "scale-down"
345
+ ]
346
+ | None
347
+ ```
348
+
349
+ </td>
350
+ <td align="left"><code>None</code></td>
351
+ <td align="left">CSS object-fit property for the thumbnail images in the gallery. Can be "contain", "cover", "fill", "none", or "scale-down".</td>
352
+ </tr>
353
+
354
+ <tr>
355
+ <td align="left"><code>show_share_button</code></td>
356
+ <td align="left" style="width: 25%;">
357
+
358
+ ```python
359
+ bool | None
360
+ ```
361
+
362
+ </td>
363
+ <td align="left"><code>None</code></td>
364
+ <td align="left">If True, will show a share icon in the corner of the component that allows user to share outputs to Hugging Face Spaces Discussions. If False, icon does not appear. If set to None (default behavior), then the icon appears if this Gradio app is launched on Spaces, but not otherwise.</td>
365
+ </tr>
366
+
367
+ <tr>
368
+ <td align="left"><code>show_download_button</code></td>
369
+ <td align="left" style="width: 25%;">
370
+
371
+ ```python
372
+ bool | None
373
+ ```
374
+
375
+ </td>
376
+ <td align="left"><code>True</code></td>
377
+ <td align="left">If True, will show a download button in the corner of the selected image. If False, the icon does not appear. Default is True.</td>
378
+ </tr>
379
+
380
+ <tr>
381
+ <td align="left"><code>interactive</code></td>
382
+ <td align="left" style="width: 25%;">
383
+
384
+ ```python
385
+ bool | None
386
+ ```
387
+
388
+ </td>
389
+ <td align="left"><code>None</code></td>
390
+ <td align="left">If True, the gallery will be interactive, allowing the user to upload images. If False, the gallery will be static. Default is True.</td>
391
+ </tr>
392
+
393
+ <tr>
394
+ <td align="left"><code>type</code></td>
395
+ <td align="left" style="width: 25%;">
396
+
397
+ ```python
398
+ Literal["numpy", "pil", "filepath"]
399
+ ```
400
+
401
+ </td>
402
+ <td align="left"><code>"filepath"</code></td>
403
+ <td align="left">The format the image is converted to before being passed into the prediction function. "numpy" converts the image to a numpy array with shape (height, width, 3) and values from 0 to 255, "pil" converts the image to a PIL image object, "filepath" passes a str path to a temporary file containing the image. If the image is SVG, the `type` is ignored and the filepath of the SVG is returned.</td>
404
+ </tr>
405
+
406
+ <tr>
407
+ <td align="left"><code>show_fullscreen_button</code></td>
408
+ <td align="left" style="width: 25%;">
409
+
410
+ ```python
411
+ bool
412
+ ```
413
+
414
+ </td>
415
+ <td align="left"><code>True</code></td>
416
+ <td align="left">If True, will show a fullscreen icon in the corner of the component that allows user to view the gallery in fullscreen mode. If False, icon does not appear. If set to None (default behavior), then the icon appears if this Gradio app is launched on Spaces, but not otherwise.</td>
417
+ </tr>
418
+ </tbody></table>
419
+
420
+
421
+ ### Events
422
+
423
+ | name | description |
424
+ |:-----|:------------|
425
+ | `select` | Event listener for when the user selects or deselects the MediaGallery. Uses event data gradio.SelectData to carry `value` referring to the label of the MediaGallery, and `selected` to refer to state of the MediaGallery. See EventData documentation on how to use this event data |
426
+ | `upload` | This listener is triggered when the user uploads a file into the MediaGallery. |
427
+ | `change` | Triggered when the value of the MediaGallery 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. |
428
+ | `preview_close` | This event is triggered when the MediaGallery preview is closed by the user |
429
+ | `preview_open` | This event is triggered when the MediaGallery preview is opened by the user |
430
+
431
+
432
+
433
+ ### User function
434
+
435
+ 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).
436
+
437
+ - When used as an Input, the component only impacts the input signature of the user function.
438
+ - When used as an output, the component only impacts the return signature of the user function.
439
+
440
+ The code snippet below is accurate in cases where the component is used as both an input and an output.
441
+
442
+ - **As output:** Is passed, passes the list of images or videos as a list of (media, caption) tuples, or a list of (media, None) tuples if no captions are provided (which is usually the case). Images can be a `str` file path, a `numpy` array, or a `PIL.Image` object depending on `type`. Videos are always `str` file path.
443
+ - **As input:** Should return, expects the function to return a `list` of images or videos, or `list` of (media, `str` caption) tuples. Each image can be a `str` file path, a `numpy` array, or a `PIL.Image` object. Each video can be a `str` file path.
444
+
445
+ ```python
446
+ def predict(
447
+ value: list[tuple[str, str | None]]
448
+ | list[tuple[PIL.Image.Image, str | None]]
449
+ | list[tuple[numpy.ndarray, str | None]]
450
+ | None
451
+ ) -> list[
452
+ typing.Union[
453
+ numpy.ndarray,
454
+ PIL.Image.Image,
455
+ pathlib.Path,
456
+ str,
457
+ tuple[
458
+ typing.Union[
459
+ numpy.ndarray,
460
+ PIL.Image.Image,
461
+ pathlib.Path,
462
+ str,
463
+ ],
464
+ str,
465
+ ],
466
+ ][
467
+ numpy.ndarray,
468
+ PIL.Image.Image,
469
+ pathlib.Path,
470
+ str,
471
+ tuple[
472
+ typing.Union[
473
+ numpy.ndarray,
474
+ PIL.Image.Image,
475
+ pathlib.Path,
476
+ str,
477
+ ][
478
+ numpy.ndarray,
479
+ PIL.Image.Image,
480
+ pathlib.Path,
481
+ str,
482
+ ],
483
+ str,
484
+ ],
485
+ ]
486
+ ]
487
+ | None:
488
+ return value
489
+ ```
490
+
mediagallery/backend/gradio_mediagallery/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+
2
+ from .mediagallery import MediaGallery
3
+
4
+ __all__ = ['MediaGallery']
mediagallery/backend/gradio_mediagallery/mediagallery.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """gr.Gallery() component."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from pathlib import Path
8
+ from typing import (
9
+ TYPE_CHECKING,
10
+ Any,
11
+ Literal,
12
+ Optional,
13
+ Union,
14
+ )
15
+ from urllib.parse import quote, urlparse
16
+
17
+ import numpy as np
18
+ import PIL.Image
19
+ from gradio_client import handle_file
20
+ from gradio_client import utils as client_utils
21
+ from gradio_client.documentation import document
22
+ from gradio_client.utils import is_http_url_like
23
+
24
+ from gradio import image_utils, processing_utils, utils
25
+ try:
26
+ from gradio import wasm_utils
27
+ IS_WASM = wasm_utils.IS_WASM
28
+ except ImportError:
29
+ IS_WASM = False
30
+ from gradio.components.base import Component
31
+ from gradio.data_classes import FileData, GradioModel, GradioRootModel, ImageData
32
+ from gradio.events import EventListener, Events
33
+ from gradio.exceptions import Error
34
+ from gradio.i18n import I18nData
35
+
36
+ if TYPE_CHECKING:
37
+ from gradio.components import Timer
38
+
39
+ GalleryMediaType = Union[np.ndarray, PIL.Image.Image, Path, str]
40
+ CaptionedGalleryMediaType = tuple[GalleryMediaType, str]
41
+
42
+
43
+ class GalleryImage(GradioModel):
44
+ image: ImageData
45
+ caption: Optional[str] = None
46
+
47
+
48
+ class GalleryVideo(GradioModel):
49
+ video: FileData
50
+ caption: Optional[str] = None
51
+
52
+
53
+ class GalleryAudio(GradioModel):
54
+ audio: FileData
55
+ caption: Optional[str] = None
56
+
57
+
58
+ class GalleryData(GradioRootModel):
59
+ root: list[Union[GalleryImage, GalleryVideo, GalleryAudio]]
60
+
61
+
62
+ # File extension mappings for media type detection
63
+ IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.svg'}
64
+ VIDEO_EXTENSIONS = {'.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm', '.mpg', '.mpeg', '.m4v', '.3gp', '.3g2', '.3gpp'}
65
+ AUDIO_EXTENSIONS = {'.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac'}
66
+
67
+
68
+ class MediaGallery(Component):
69
+ """
70
+ Creates a gallery component that allows displaying a grid of images or videos, and optionally captions. If used as an input, the user can upload images or videos to the gallery.
71
+ If used as an output, the user can click on individual images or videos to view them at a higher resolution.
72
+
73
+ Demos: fake_gan
74
+ """
75
+
76
+ EVENTS = [
77
+ Events.select,
78
+ Events.upload,
79
+ Events.change,
80
+ EventListener(
81
+ "preview_close",
82
+ doc="This event is triggered when the MediaGallery preview is closed by the user",
83
+ ),
84
+ EventListener(
85
+ "preview_open",
86
+ doc="This event is triggered when the MediaGallery preview is opened by the user",
87
+ ),
88
+ ]
89
+
90
+ data_model = GalleryData
91
+
92
+ def __init__(
93
+ self,
94
+ value: (
95
+ Sequence[np.ndarray | PIL.Image.Image | str | Path | tuple]
96
+ | Callable
97
+ | None
98
+ ) = None,
99
+ *,
100
+ format: str = "webp",
101
+ file_types: list[str] | None = None,
102
+ label: str | I18nData | None = None,
103
+ every: Timer | float | None = None,
104
+ inputs: Component | Sequence[Component] | set[Component] | None = None,
105
+ show_label: bool | None = None,
106
+ container: bool = True,
107
+ scale: int | None = None,
108
+ min_width: int = 160,
109
+ visible: bool = True,
110
+ elem_id: str | None = None,
111
+ elem_classes: list[str] | str | None = None,
112
+ render: bool = True,
113
+ key: int | str | tuple[int | str, ...] | None = None,
114
+ preserved_by_key: list[str] | str | None = "value",
115
+ columns: int | None = 2,
116
+ rows: int | None = None,
117
+ height: int | float | str | None = None,
118
+ allow_preview: bool = True,
119
+ preview: bool | None = None,
120
+ selected_index: int | None = None,
121
+ object_fit: (
122
+ Literal["contain", "cover", "fill", "none", "scale-down"] | None
123
+ ) = None,
124
+ show_share_button: bool | None = None,
125
+ show_download_button: bool | None = True,
126
+ interactive: bool | None = None,
127
+ type: Literal["numpy", "pil", "filepath"] = "filepath",
128
+ show_fullscreen_button: bool = True,
129
+ ):
130
+ """
131
+ Parameters:
132
+ value: List of images or videos to display in the gallery by default. If a function is provided, the function will be called each time the app loads to set the initial value of this component.
133
+ format: Format to save images before they are returned to the frontend, such as 'jpeg' or 'png'. This parameter only applies to images that are returned from the prediction function as numpy arrays or PIL Images. The format should be supported by the PIL library.
134
+ file_types: List of file extensions or types of files to be uploaded (e.g. ['image', '.mp4']), when this is used as an input component. "image" allows only image files to be uploaded, "video" allows only video files to be uploaded, ".mp4" allows only mp4 files to be uploaded, etc. If None, any image and video files types are allowed.
135
+ label: the label for this component. Appears above the component and is also used as the header if there are a table of examples for this component. If None and used in a `gr.Interface`, the label will be the name of the parameter this component is assigned to.
136
+ every: Continously calls `value` to recalculate it if `value` is a function (has no effect otherwise). Can provide a Timer whose tick resets `value`, or a float that provides the regular interval for the reset Timer.
137
+ inputs: Components that are used as inputs to calculate `value` if `value` is a function (has no effect otherwise). `value` is recalculated any time the inputs change.
138
+ show_label: if True, will display label.
139
+ container: If True, will place the component in a container - providing some extra padding around the border.
140
+ scale: relative size compared to adjacent Components. For example if Components A and B are in a Row, and A has scale=2, and B has scale=1, A will be twice as wide as B. Should be an integer. scale applies in Rows, and to top-level Components in Blocks where fill_height=True.
141
+ min_width: minimum pixel width, will wrap if not sufficient screen space to satisfy this value. If a certain scale value results in this Component being narrower than min_width, the min_width parameter will be respected first.
142
+ visible: If False, component will be hidden.
143
+ elem_id: An optional string that is assigned as the id of this component in the HTML DOM. Can be used for targeting CSS styles.
144
+ elem_classes: An optional list of strings that are assigned as the classes of this component in the HTML DOM. Can be used for targeting CSS styles.
145
+ render: If False, component will not render be rendered in the Blocks context. Should be used if the intention is to assign event listeners now but render the component later.
146
+ key: in a gr.render, Components with the same key across re-renders are treated as the same component, not a new component. Properties set in 'preserved_by_key' are not reset across a re-render.
147
+ preserved_by_key: A list of parameters from this component's constructor. Inside a gr.render() function, if a component is re-rendered with the same key, these (and only these) parameters will be preserved in the UI (if they have been changed by the user or an event listener) instead of re-rendered based on the values provided during constructor.
148
+ columns: Represents the number of images that should be shown in one row.
149
+ rows: Represents the number of rows in the image grid.
150
+ height: The height of the gallery component, specified in pixels if a number is passed, or in CSS units if a string is passed. If more images are displayed than can fit in the height, a scrollbar will appear.
151
+ allow_preview: If True, images in the gallery will be enlarged when they are clicked. Default is True.
152
+ preview: If True, MediaGallery will start in preview mode, which shows all of the images as thumbnails and allows the user to click on them to view them in full size. Only works if allow_preview is True.
153
+ selected_index: The index of the image that should be initially selected. If None, no image will be selected at start. If provided, will set MediaGallery to preview mode unless allow_preview is set to False.
154
+ object_fit: CSS object-fit property for the thumbnail images in the gallery. Can be "contain", "cover", "fill", "none", or "scale-down".
155
+ show_share_button: If True, will show a share icon in the corner of the component that allows user to share outputs to Hugging Face Spaces Discussions. If False, icon does not appear. If set to None (default behavior), then the icon appears if this Gradio app is launched on Spaces, but not otherwise.
156
+ show_download_button: If True, will show a download button in the corner of the selected image. If False, the icon does not appear. Default is True.
157
+ interactive: If True, the gallery will be interactive, allowing the user to upload images. If False, the gallery will be static. Default is True.
158
+ type: The format the image is converted to before being passed into the prediction function. "numpy" converts the image to a numpy array with shape (height, width, 3) and values from 0 to 255, "pil" converts the image to a PIL image object, "filepath" passes a str path to a temporary file containing the image. If the image is SVG, the `type` is ignored and the filepath of the SVG is returned.
159
+ show_fullscreen_button: If True, will show a fullscreen icon in the corner of the component that allows user to view the gallery in fullscreen mode. If False, icon does not appear. If set to None (default behavior), then the icon appears if this Gradio app is launched on Spaces, but not otherwise.
160
+ """
161
+ self.format = format
162
+ self.columns = columns
163
+ self.rows = rows
164
+ self.height = height
165
+ self.preview = preview
166
+ self.object_fit = object_fit
167
+ self.allow_preview = allow_preview
168
+ self.show_download_button = (
169
+ (utils.get_space() is not None)
170
+ if show_download_button is None
171
+ else show_download_button
172
+ )
173
+ self.selected_index = selected_index
174
+ self.type = type
175
+ self.show_fullscreen_button = show_fullscreen_button
176
+ self.file_types = file_types
177
+
178
+ self.show_share_button = (
179
+ (utils.get_space() is not None)
180
+ if show_share_button is None
181
+ else show_share_button
182
+ )
183
+ super().__init__(
184
+ label=label,
185
+ every=every,
186
+ inputs=inputs,
187
+ show_label=show_label,
188
+ container=container,
189
+ scale=scale,
190
+ min_width=min_width,
191
+ visible=visible,
192
+ elem_id=elem_id,
193
+ elem_classes=elem_classes,
194
+ render=render,
195
+ key=key,
196
+ preserved_by_key=preserved_by_key,
197
+ value=value,
198
+ interactive=interactive,
199
+ )
200
+ self._value_description = f"a list of {'string filepaths' if type == 'filepath' else 'numpy arrays' if type == 'numpy' else 'PIL images'}"
201
+
202
+ def preprocess(
203
+ self, payload: GalleryData | None
204
+ ) -> (
205
+ list[tuple[str, str | None]]
206
+ | list[tuple[PIL.Image.Image, str | None]]
207
+ | list[tuple[np.ndarray, str | None]]
208
+ | None
209
+ ):
210
+ """
211
+ Parameters:
212
+ payload: a list of images or videos, or list of (media, caption) tuples
213
+ Returns:
214
+ Passes the list of images or videos as a list of (media, caption) tuples, or a list of (media, None) tuples if no captions are provided (which is usually the case). Images can be a `str` file path, a `numpy` array, or a `PIL.Image` object depending on `type`. Videos are always `str` file path.
215
+ """
216
+ if payload is None or not payload.root:
217
+ return None
218
+ data = []
219
+ for gallery_element in payload.root:
220
+ if isinstance(gallery_element, GalleryVideo):
221
+ file_path = gallery_element.video.path
222
+ elif isinstance(gallery_element, GalleryAudio):
223
+ file_path = gallery_element.audio.path
224
+ else:
225
+ file_path = gallery_element.image.path or ""
226
+ if self.file_types and not client_utils.is_valid_file(
227
+ file_path, self.file_types
228
+ ):
229
+ raise Error(
230
+ f"Invalid file type. Please upload a file that is one of these formats: {self.file_types}"
231
+ )
232
+ else:
233
+ # Return file path for video and audio, convert images based on type
234
+ if isinstance(gallery_element, GalleryVideo):
235
+ media = gallery_element.video.path
236
+ elif isinstance(gallery_element, GalleryAudio):
237
+ media = gallery_element.audio.path
238
+ else:
239
+ media = self.convert_to_type(gallery_element.image.path, self.type) # type: ignore
240
+ data.append((media, gallery_element.caption))
241
+ return data
242
+
243
+ def postprocess(
244
+ self,
245
+ value: list[GalleryMediaType | CaptionedGalleryMediaType] | None,
246
+ ) -> GalleryData:
247
+ """
248
+ Parameters:
249
+ value: Expects the function to return a `list` of images or videos, or `list` of (media, `str` caption) tuples. Each image can be a `str` file path, a `numpy` array, or a `PIL.Image` object. Each video can be a `str` file path.
250
+ Returns:
251
+ a list of images or videos, or list of (media, caption) tuples
252
+ """
253
+ if value is None:
254
+ return GalleryData(root=[])
255
+ if isinstance(value, str):
256
+ raise ValueError(
257
+ "The `value` passed into `gr.Gallery` must be a list of images or videos, or list of (media, caption) tuples."
258
+ )
259
+ output = []
260
+
261
+ def _save(img):
262
+ url = None
263
+ caption = None
264
+ orig_name = None
265
+ mime_type = None
266
+ if isinstance(img, (tuple, list)):
267
+ img, caption = img
268
+ if isinstance(img, np.ndarray):
269
+ file = processing_utils.save_img_array_to_cache(
270
+ img, cache_dir=self.GRADIO_CACHE, format=self.format
271
+ )
272
+ file_path = str(utils.abspath(file))
273
+ elif isinstance(img, PIL.Image.Image):
274
+ file = processing_utils.save_pil_to_cache(
275
+ img, cache_dir=self.GRADIO_CACHE, format=self.format
276
+ )
277
+ file_path = str(utils.abspath(file))
278
+ elif isinstance(img, str):
279
+ mime_type = client_utils.get_mimetype(img)
280
+ if img.lower().endswith(".svg"):
281
+ svg_content = image_utils.extract_svg_content(img)
282
+ orig_name = Path(img).name
283
+ url = f"data:image/svg+xml,{quote(svg_content)}"
284
+ file_path = None
285
+ elif is_http_url_like(img):
286
+ url = img
287
+ orig_name = Path(urlparse(img).path).name
288
+ file_path = img
289
+ else:
290
+ url = None
291
+ orig_name = Path(img).name
292
+ file_path = img
293
+ elif isinstance(img, Path):
294
+ file_path = str(img)
295
+ orig_name = img.name
296
+ mime_type = client_utils.get_mimetype(file_path)
297
+ else:
298
+ raise ValueError(f"Cannot process type as image: {type(img)}")
299
+ # Determine media type from mime_type or file extension
300
+ if mime_type is not None and "video" in mime_type:
301
+ return GalleryVideo(
302
+ video=FileData(
303
+ path=file_path, # type: ignore
304
+ url=url,
305
+ orig_name=orig_name,
306
+ mime_type=mime_type,
307
+ ),
308
+ caption=caption,
309
+ )
310
+ elif mime_type is not None and "audio" in mime_type:
311
+ return GalleryAudio(
312
+ audio=FileData(
313
+ path=file_path, # type: ignore
314
+ url=url,
315
+ orig_name=orig_name,
316
+ mime_type=mime_type,
317
+ ),
318
+ caption=caption,
319
+ )
320
+ else:
321
+ # Check file extension for audio files (fallback)
322
+ ext = Path(orig_name or file_path or "").suffix.lower() if (orig_name or file_path) else ""
323
+ if ext in AUDIO_EXTENSIONS:
324
+ return GalleryAudio(
325
+ audio=FileData(
326
+ path=file_path, # type: ignore
327
+ url=url,
328
+ orig_name=orig_name,
329
+ mime_type=mime_type or "audio/mpeg",
330
+ ),
331
+ caption=caption,
332
+ )
333
+ return GalleryImage(
334
+ image=ImageData(
335
+ path=file_path,
336
+ url=url,
337
+ orig_name=orig_name,
338
+ mime_type=mime_type,
339
+ ),
340
+ caption=caption,
341
+ )
342
+
343
+ if IS_WASM:
344
+ for img in value:
345
+ output.append(_save(img))
346
+ else:
347
+ with ThreadPoolExecutor() as executor:
348
+ for o in executor.map(_save, value):
349
+ output.append(o)
350
+ return GalleryData(root=output)
351
+
352
+ @staticmethod
353
+ def convert_to_type(img: str, type: Literal["filepath", "numpy", "pil"]):
354
+ if type == "filepath":
355
+ return img
356
+ else:
357
+ converted_image = PIL.Image.open(img)
358
+ if type == "numpy":
359
+ converted_image = np.array(converted_image)
360
+ return converted_image
361
+
362
+ def example_payload(self) -> Any:
363
+ return [
364
+ {
365
+ "image": handle_file(
366
+ "https://raw.githubusercontent.com/gradio-app/gradio/main/test/test_files/bus.png"
367
+ )
368
+ },
369
+ ]
370
+
371
+ def example_value(self) -> Any:
372
+ return [
373
+ "https://raw.githubusercontent.com/gradio-app/gradio/main/test/test_files/bus.png"
374
+ ]
mediagallery/demo/__init__.py ADDED
File without changes
mediagallery/demo/app.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import gradio as gr
3
+ from gradio_mediagallery import MediaGallery
4
+
5
+
6
+ example = MediaGallery().example_value()
7
+
8
+ with gr.Blocks() as demo:
9
+ with gr.Row():
10
+ MediaGallery(label="Blank"), # blank component
11
+ MediaGallery(value=example, label="Populated"), # populated component
12
+
13
+
14
+ if __name__ == "__main__":
15
+ demo.launch()
mediagallery/demo/css.css ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ html {
2
+ font-family: Inter;
3
+ font-size: 16px;
4
+ font-weight: 400;
5
+ line-height: 1.5;
6
+ -webkit-text-size-adjust: 100%;
7
+ background: #fff;
8
+ color: #323232;
9
+ -webkit-font-smoothing: antialiased;
10
+ -moz-osx-font-smoothing: grayscale;
11
+ text-rendering: optimizeLegibility;
12
+ }
13
+
14
+ :root {
15
+ --space: 1;
16
+ --vspace: calc(var(--space) * 1rem);
17
+ --vspace-0: calc(3 * var(--space) * 1rem);
18
+ --vspace-1: calc(2 * var(--space) * 1rem);
19
+ --vspace-2: calc(1.5 * var(--space) * 1rem);
20
+ --vspace-3: calc(0.5 * var(--space) * 1rem);
21
+ }
22
+
23
+ .app {
24
+ max-width: 748px !important;
25
+ }
26
+
27
+ .prose p {
28
+ margin: var(--vspace) 0;
29
+ line-height: var(--vspace * 2);
30
+ font-size: 1rem;
31
+ }
32
+
33
+ code {
34
+ font-family: "Inconsolata", sans-serif;
35
+ font-size: 16px;
36
+ }
37
+
38
+ h1,
39
+ h1 code {
40
+ font-weight: 400;
41
+ line-height: calc(2.5 / var(--space) * var(--vspace));
42
+ }
43
+
44
+ h1 code {
45
+ background: none;
46
+ border: none;
47
+ letter-spacing: 0.05em;
48
+ padding-bottom: 5px;
49
+ position: relative;
50
+ padding: 0;
51
+ }
52
+
53
+ h2 {
54
+ margin: var(--vspace-1) 0 var(--vspace-2) 0;
55
+ line-height: 1em;
56
+ }
57
+
58
+ h3,
59
+ h3 code {
60
+ margin: var(--vspace-1) 0 var(--vspace-2) 0;
61
+ line-height: 1em;
62
+ }
63
+
64
+ h4,
65
+ h5,
66
+ h6 {
67
+ margin: var(--vspace-3) 0 var(--vspace-3) 0;
68
+ line-height: var(--vspace);
69
+ }
70
+
71
+ .bigtitle,
72
+ h1,
73
+ h1 code {
74
+ font-size: calc(8px * 4.5);
75
+ word-break: break-word;
76
+ }
77
+
78
+ .title,
79
+ h2,
80
+ h2 code {
81
+ font-size: calc(8px * 3.375);
82
+ font-weight: lighter;
83
+ word-break: break-word;
84
+ border: none;
85
+ background: none;
86
+ }
87
+
88
+ .subheading1,
89
+ h3,
90
+ h3 code {
91
+ font-size: calc(8px * 1.8);
92
+ font-weight: 600;
93
+ border: none;
94
+ background: none;
95
+ letter-spacing: 0.1em;
96
+ text-transform: uppercase;
97
+ }
98
+
99
+ h2 code {
100
+ padding: 0;
101
+ position: relative;
102
+ letter-spacing: 0.05em;
103
+ }
104
+
105
+ blockquote {
106
+ font-size: calc(8px * 1.1667);
107
+ font-style: italic;
108
+ line-height: calc(1.1667 * var(--vspace));
109
+ margin: var(--vspace-2) var(--vspace-2);
110
+ }
111
+
112
+ .subheading2,
113
+ h4 {
114
+ font-size: calc(8px * 1.4292);
115
+ text-transform: uppercase;
116
+ font-weight: 600;
117
+ }
118
+
119
+ .subheading3,
120
+ h5 {
121
+ font-size: calc(8px * 1.2917);
122
+ line-height: calc(1.2917 * var(--vspace));
123
+
124
+ font-weight: lighter;
125
+ text-transform: uppercase;
126
+ letter-spacing: 0.15em;
127
+ }
128
+
129
+ h6 {
130
+ font-size: calc(8px * 1.1667);
131
+ font-size: 1.1667em;
132
+ font-weight: normal;
133
+ font-style: italic;
134
+ font-family: "le-monde-livre-classic-byol", serif !important;
135
+ letter-spacing: 0px !important;
136
+ }
137
+
138
+ #start .md > *:first-child {
139
+ margin-top: 0;
140
+ }
141
+
142
+ h2 + h3 {
143
+ margin-top: 0;
144
+ }
145
+
146
+ .md hr {
147
+ border: none;
148
+ border-top: 1px solid var(--block-border-color);
149
+ margin: var(--vspace-2) 0 var(--vspace-2) 0;
150
+ }
151
+ .prose ul {
152
+ margin: var(--vspace-2) 0 var(--vspace-1) 0;
153
+ }
154
+
155
+ .gap {
156
+ gap: 0;
157
+ }
mediagallery/demo/space.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import gradio as gr
3
+ from app import demo as app
4
+ import os
5
+
6
+ _docs = {'MediaGallery': {'description': 'Creates a gallery component that allows displaying a grid of images or videos, and optionally captions. If used as an input, the user can upload images or videos to the gallery.\nIf used as an output, the user can click on individual images or videos to view them at a higher resolution.\n', 'members': {'__init__': {'value': {'type': 'Sequence[\n np.ndarray | PIL.Image.Image | str | Path | tuple\n ]\n | Callable\n | None', 'default': 'None', 'description': 'List of images or videos to display in the gallery by default. If a function is provided, the function will be called each time the app loads to set the initial value of this component.'}, 'format': {'type': 'str', 'default': '"webp"', 'description': "Format to save images before they are returned to the frontend, such as 'jpeg' or 'png'. This parameter only applies to images that are returned from the prediction function as numpy arrays or PIL Images. The format should be supported by the PIL library."}, 'file_types': {'type': 'list[str] | None', 'default': 'None', 'description': 'List of file extensions or types of files to be uploaded (e.g. [\'image\', \'.mp4\']), when this is used as an input component. "image" allows only image files to be uploaded, "video" allows only video files to be uploaded, ".mp4" allows only mp4 files to be uploaded, etc. If None, any image and video files types are allowed.'}, 'label': {'type': 'str | I18nData | None', 'default': 'None', 'description': 'the label for this component. Appears above the component and is also used as the header if there are a table of examples for this component. If None and used in a `gr.Interface`, the label will be the name of the parameter this component is assigned to.'}, 'every': {'type': 'Timer | float | None', 'default': 'None', 'description': 'Continously calls `value` to recalculate it if `value` is a function (has no effect otherwise). Can provide a Timer whose tick resets `value`, or a float that provides the regular interval for the reset Timer.'}, 'inputs': {'type': 'Component | Sequence[Component] | set[Component] | None', 'default': 'None', 'description': 'Components that are used as inputs to calculate `value` if `value` is a function (has no effect otherwise). `value` is recalculated any time the inputs change.'}, 'show_label': {'type': 'bool | None', 'default': 'None', 'description': 'if True, will display label.'}, 'container': {'type': 'bool', 'default': 'True', 'description': 'If True, will place the component in a container - providing some extra padding around the border.'}, 'scale': {'type': 'int | None', 'default': 'None', 'description': 'relative size compared to adjacent Components. For example if Components A and B are in a Row, and A has scale=2, and B has scale=1, A will be twice as wide as B. Should be an integer. scale applies in Rows, and to top-level Components in Blocks where fill_height=True.'}, 'min_width': {'type': 'int', 'default': '160', 'description': 'minimum pixel width, will wrap if not sufficient screen space to satisfy this value. If a certain scale value results in this Component being narrower than min_width, the min_width parameter will be respected first.'}, 'visible': {'type': 'bool', 'default': 'True', 'description': 'If False, component will be hidden.'}, 'elem_id': {'type': 'str | None', 'default': 'None', 'description': 'An optional string that is assigned as the id of this component in the HTML DOM. Can be used for targeting CSS styles.'}, 'elem_classes': {'type': 'list[str] | str | None', 'default': 'None', 'description': 'An optional list of strings that are assigned as the classes of this component in the HTML DOM. Can be used for targeting CSS styles.'}, 'render': {'type': 'bool', 'default': 'True', 'description': 'If False, component will not render be rendered in the Blocks context. Should be used if the intention is to assign event listeners now but render the component later.'}, 'key': {'type': 'int | str | tuple[int | str, ...] | None', 'default': 'None', 'description': "in a gr.render, Components with the same key across re-renders are treated as the same component, not a new component. Properties set in 'preserved_by_key' are not reset across a re-render."}, 'preserved_by_key': {'type': 'list[str] | str | None', 'default': '"value"', 'description': "A list of parameters from this component's constructor. Inside a gr.render() function, if a component is re-rendered with the same key, these (and only these) parameters will be preserved in the UI (if they have been changed by the user or an event listener) instead of re-rendered based on the values provided during constructor."}, 'columns': {'type': 'int | None', 'default': '2', 'description': 'Represents the number of images that should be shown in one row.'}, 'rows': {'type': 'int | None', 'default': 'None', 'description': 'Represents the number of rows in the image grid.'}, 'height': {'type': 'int | float | str | None', 'default': 'None', 'description': 'The height of the gallery component, specified in pixels if a number is passed, or in CSS units if a string is passed. If more images are displayed than can fit in the height, a scrollbar will appear.'}, 'allow_preview': {'type': 'bool', 'default': 'True', 'description': 'If True, images in the gallery will be enlarged when they are clicked. Default is True.'}, 'preview': {'type': 'bool | None', 'default': 'None', 'description': 'If True, MediaGallery will start in preview mode, which shows all of the images as thumbnails and allows the user to click on them to view them in full size. Only works if allow_preview is True.'}, 'selected_index': {'type': 'int | None', 'default': 'None', 'description': 'The index of the image that should be initially selected. If None, no image will be selected at start. If provided, will set MediaGallery to preview mode unless allow_preview is set to False.'}, 'object_fit': {'type': 'Literal[\n "contain", "cover", "fill", "none", "scale-down"\n ]\n | None', 'default': 'None', 'description': 'CSS object-fit property for the thumbnail images in the gallery. Can be "contain", "cover", "fill", "none", or "scale-down".'}, 'show_share_button': {'type': 'bool | None', 'default': 'None', 'description': 'If True, will show a share icon in the corner of the component that allows user to share outputs to Hugging Face Spaces Discussions. If False, icon does not appear. If set to None (default behavior), then the icon appears if this Gradio app is launched on Spaces, but not otherwise.'}, 'show_download_button': {'type': 'bool | None', 'default': 'True', 'description': 'If True, will show a download button in the corner of the selected image. If False, the icon does not appear. Default is True.'}, 'interactive': {'type': 'bool | None', 'default': 'None', 'description': 'If True, the gallery will be interactive, allowing the user to upload images. If False, the gallery will be static. Default is True.'}, 'type': {'type': 'Literal["numpy", "pil", "filepath"]', 'default': '"filepath"', 'description': 'The format the image is converted to before being passed into the prediction function. "numpy" converts the image to a numpy array with shape (height, width, 3) and values from 0 to 255, "pil" converts the image to a PIL image object, "filepath" passes a str path to a temporary file containing the image. If the image is SVG, the `type` is ignored and the filepath of the SVG is returned.'}, 'show_fullscreen_button': {'type': 'bool', 'default': 'True', 'description': 'If True, will show a fullscreen icon in the corner of the component that allows user to view the gallery in fullscreen mode. If False, icon does not appear. If set to None (default behavior), then the icon appears if this Gradio app is launched on Spaces, but not otherwise.'}}, 'postprocess': {'value': {'type': 'list[\n typing.Union[\n numpy.ndarray,\n PIL.Image.Image,\n pathlib.Path,\n str,\n tuple[\n typing.Union[\n numpy.ndarray,\n PIL.Image.Image,\n pathlib.Path,\n str,\n ],\n str,\n ],\n ][\n numpy.ndarray,\n PIL.Image.Image,\n pathlib.Path,\n str,\n tuple[\n typing.Union[\n numpy.ndarray,\n PIL.Image.Image,\n pathlib.Path,\n str,\n ][\n numpy.ndarray,\n PIL.Image.Image,\n pathlib.Path,\n str,\n ],\n str,\n ],\n ]\n ]\n | None', 'description': 'Expects the function to return a `list` of images or videos, or `list` of (media, `str` caption) tuples. Each image can be a `str` file path, a `numpy` array, or a `PIL.Image` object. Each video can be a `str` file path.'}}, 'preprocess': {'return': {'type': 'list[tuple[str, str | None]]\n | list[tuple[PIL.Image.Image, str | None]]\n | list[tuple[numpy.ndarray, str | None]]\n | None', 'description': 'Passes the list of images or videos as a list of (media, caption) tuples, or a list of (media, None) tuples if no captions are provided (which is usually the case). Images can be a `str` file path, a `numpy` array, or a `PIL.Image` object depending on `type`. Videos are always `str` file path.'}, 'value': None}}, 'events': {'select': {'type': None, 'default': None, 'description': 'Event listener for when the user selects or deselects the MediaGallery. Uses event data gradio.SelectData to carry `value` referring to the label of the MediaGallery, and `selected` to refer to state of the MediaGallery. See EventData documentation on how to use this event data'}, 'upload': {'type': None, 'default': None, 'description': 'This listener is triggered when the user uploads a file into the MediaGallery.'}, 'change': {'type': None, 'default': None, 'description': 'Triggered when the value of the MediaGallery 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.'}, 'preview_close': {'type': None, 'default': None, 'description': 'This event is triggered when the MediaGallery preview is closed by the user'}, 'preview_open': {'type': None, 'default': None, 'description': 'This event is triggered when the MediaGallery preview is opened by the user'}}}, '__meta__': {'additional_interfaces': {}, 'user_fn_refs': {'MediaGallery': []}}}
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_mediagallery`
22
+
23
+ <div style="display: flex; gap: 7px;">
24
+ <a href="https://pypi.org/project/gradio_mediagallery/" target="_blank"><img alt="PyPI - Version" src="https://img.shields.io/pypi/v/gradio_mediagallery"></a>
25
+ </div>
26
+
27
+ Python library for easily interacting with trained machine learning models
28
+ """, elem_classes=["md-custom"], header_links=True)
29
+ app.render()
30
+ gr.Markdown(
31
+ """
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install gradio_mediagallery
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+
42
+ import gradio as gr
43
+ from gradio_mediagallery import MediaGallery
44
+
45
+
46
+ example = MediaGallery().example_value()
47
+
48
+ with gr.Blocks() as demo:
49
+ with gr.Row():
50
+ MediaGallery(label="Blank"), # blank component
51
+ MediaGallery(value=example, label="Populated"), # populated component
52
+
53
+
54
+ if __name__ == "__main__":
55
+ demo.launch()
56
+
57
+ ```
58
+ """, elem_classes=["md-custom"], header_links=True)
59
+
60
+
61
+ gr.Markdown("""
62
+ ## `MediaGallery`
63
+
64
+ ### Initialization
65
+ """, elem_classes=["md-custom"], header_links=True)
66
+
67
+ gr.ParamViewer(value=_docs["MediaGallery"]["members"]["__init__"], linkify=[])
68
+
69
+
70
+ gr.Markdown("### Events")
71
+ gr.ParamViewer(value=_docs["MediaGallery"]["events"], linkify=['Event'])
72
+
73
+
74
+
75
+
76
+ gr.Markdown("""
77
+
78
+ ### User function
79
+
80
+ 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).
81
+
82
+ - When used as an Input, the component only impacts the input signature of the user function.
83
+ - When used as an output, the component only impacts the return signature of the user function.
84
+
85
+ The code snippet below is accurate in cases where the component is used as both an input and an output.
86
+
87
+ - **As input:** Is passed, passes the list of images or videos as a list of (media, caption) tuples, or a list of (media, None) tuples if no captions are provided (which is usually the case). Images can be a `str` file path, a `numpy` array, or a `PIL.Image` object depending on `type`. Videos are always `str` file path.
88
+ - **As output:** Should return, expects the function to return a `list` of images or videos, or `list` of (media, `str` caption) tuples. Each image can be a `str` file path, a `numpy` array, or a `PIL.Image` object. Each video can be a `str` file path.
89
+
90
+ ```python
91
+ def predict(
92
+ value: list[tuple[str, str | None]]
93
+ | list[tuple[PIL.Image.Image, str | None]]
94
+ | list[tuple[numpy.ndarray, str | None]]
95
+ | None
96
+ ) -> list[
97
+ typing.Union[
98
+ numpy.ndarray,
99
+ PIL.Image.Image,
100
+ pathlib.Path,
101
+ str,
102
+ tuple[
103
+ typing.Union[
104
+ numpy.ndarray,
105
+ PIL.Image.Image,
106
+ pathlib.Path,
107
+ str,
108
+ ],
109
+ str,
110
+ ],
111
+ ][
112
+ numpy.ndarray,
113
+ PIL.Image.Image,
114
+ pathlib.Path,
115
+ str,
116
+ tuple[
117
+ typing.Union[
118
+ numpy.ndarray,
119
+ PIL.Image.Image,
120
+ pathlib.Path,
121
+ str,
122
+ ][
123
+ numpy.ndarray,
124
+ PIL.Image.Image,
125
+ pathlib.Path,
126
+ str,
127
+ ],
128
+ str,
129
+ ],
130
+ ]
131
+ ]
132
+ | None:
133
+ return value
134
+ ```
135
+ """, elem_classes=["md-custom", "MediaGallery-user-fn"], header_links=True)
136
+
137
+
138
+
139
+
140
+ demo.load(None, js=r"""function() {
141
+ const refs = {};
142
+ const user_fn_refs = {
143
+ MediaGallery: [], };
144
+ requestAnimationFrame(() => {
145
+
146
+ Object.entries(user_fn_refs).forEach(([key, refs]) => {
147
+ if (refs.length > 0) {
148
+ const el = document.querySelector(`.${key}-user-fn`);
149
+ if (!el) return;
150
+ refs.forEach(ref => {
151
+ el.innerHTML = el.innerHTML.replace(
152
+ new RegExp("\\b"+ref+"\\b", "g"),
153
+ `<a href="#h-${ref.toLowerCase()}">${ref}</a>`
154
+ );
155
+ })
156
+ }
157
+ })
158
+
159
+ Object.entries(refs).forEach(([key, refs]) => {
160
+ if (refs.length > 0) {
161
+ const el = document.querySelector(`.${key}`);
162
+ if (!el) return;
163
+ refs.forEach(ref => {
164
+ el.innerHTML = el.innerHTML.replace(
165
+ new RegExp("\\b"+ref+"\\b", "g"),
166
+ `<a href="#h-${ref.toLowerCase()}">${ref}</a>`
167
+ );
168
+ })
169
+ }
170
+ })
171
+ })
172
+ }
173
+
174
+ """)
175
+
176
+ demo.launch()
mediagallery/frontend/Example.svelte ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import type { GalleryImage, GalleryVideo, GalleryAudio, GalleryData } from "./types";
3
+
4
+ export let value: GalleryData[] | null;
5
+ export let type: "gallery" | "table";
6
+ export let selected = false;
7
+ </script>
8
+
9
+ <div
10
+ class="container"
11
+ class:table={type === "table"}
12
+ class:gallery={type === "gallery"}
13
+ class:selected
14
+ >
15
+ {#if value && value.length > 0}
16
+ <div class="images-wrapper">
17
+ {#each value.slice(0, 5) as item}
18
+ {#if "image" in item && item.image}
19
+ <div class="image-container">
20
+ <img src={item.image.url} alt={item.caption || ""} />
21
+ </div>
22
+ {:else if "video" in item && item.video}
23
+ <div class="image-container">
24
+ <video
25
+ src={item.video.url}
26
+ controls={false}
27
+ muted
28
+ preload="metadata"
29
+ />
30
+ </div>
31
+ {:else if "audio" in item && item.audio}
32
+ <div class="image-container audio">
33
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
34
+ <path d="M9 18V5l12-2v13"></path>
35
+ <circle cx="6" cy="18" r="3"></circle>
36
+ <circle cx="18" cy="16" r="3"></circle>
37
+ </svg>
38
+ </div>
39
+ {/if}
40
+ {/each}
41
+ {#if value.length > 5}
42
+ <div class="more-indicator">+{value.length - 5}</div>
43
+ {/if}
44
+ </div>
45
+ {/if}
46
+ </div>
47
+
48
+ <style>
49
+ .container {
50
+ border-radius: var(--radius-lg);
51
+ overflow: hidden;
52
+ }
53
+
54
+ .container.selected {
55
+ border: 2px solid var(--border-color-accent);
56
+ }
57
+
58
+ .images-wrapper {
59
+ display: flex;
60
+ gap: var(--spacing-sm);
61
+ }
62
+
63
+ .container.table .images-wrapper {
64
+ flex-direction: row;
65
+ align-items: center;
66
+ padding: var(--spacing-sm);
67
+ border: 1px solid var(--border-color-primary);
68
+ border-radius: var(--radius-lg);
69
+ background: var(--background-fill-secondary);
70
+ }
71
+
72
+ .container.gallery .images-wrapper {
73
+ flex-direction: row;
74
+ gap: 0;
75
+ }
76
+
77
+ .image-container {
78
+ position: relative;
79
+ flex-shrink: 0;
80
+ }
81
+
82
+ .container.table .image-container {
83
+ width: var(--size-12);
84
+ height: var(--size-12);
85
+ }
86
+
87
+ .container.gallery .image-container {
88
+ width: var(--size-20);
89
+ height: var(--size-20);
90
+ margin-left: calc(-1 * var(--size-8));
91
+ }
92
+
93
+ .container.gallery .image-container:first-child {
94
+ margin-left: 0;
95
+ }
96
+
97
+ .more-indicator {
98
+ display: flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ font-size: var(--text-sm);
102
+ font-weight: bold;
103
+ color: var(--body-text-color-subdued);
104
+ background: var(--background-fill-secondary);
105
+ border-radius: var(--radius-md);
106
+ }
107
+
108
+ .container.table .more-indicator {
109
+ width: var(--size-12);
110
+ height: var(--size-12);
111
+ }
112
+
113
+ .container.gallery .more-indicator {
114
+ width: var(--size-20);
115
+ height: var(--size-20);
116
+ margin-left: calc(-1 * var(--size-8));
117
+ }
118
+
119
+ .image-container img,
120
+ .image-container video {
121
+ width: 100%;
122
+ height: 100%;
123
+ object-fit: cover;
124
+ border-radius: var(--radius-md);
125
+ }
126
+
127
+ .image-container.audio {
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: center;
131
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
132
+ border-radius: var(--radius-md);
133
+ color: white;
134
+ }
135
+
136
+ /* Remove hover effects */
137
+ .container,
138
+ .container *,
139
+ .image-container,
140
+ .image-container img,
141
+ .image-container video {
142
+ transition: none !important;
143
+ }
144
+
145
+ .container:hover,
146
+ .image-container:hover,
147
+ .image-container:hover img,
148
+ .image-container:hover video {
149
+ transform: none !important;
150
+ filter: none !important;
151
+ opacity: 1 !important;
152
+ }
153
+ </style>
mediagallery/frontend/Index.svelte ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script context="module" lang="ts">
2
+ export { default as BaseGallery } from "./shared/Gallery.svelte";
3
+ export { default as BaseExample } from "./Example.svelte";
4
+ </script>
5
+
6
+ <script lang="ts">
7
+ import type { GalleryImage, GalleryVideo, GalleryAudio, GalleryData } from "./types";
8
+ import type { FileData } from "@gradio/client";
9
+ import type { Gradio, ShareData, SelectData } from "@gradio/utils";
10
+ import { Block, UploadText } from "@gradio/atoms";
11
+ import Gallery from "./shared/Gallery.svelte";
12
+ import type { LoadingStatus } from "@gradio/statustracker";
13
+ import { StatusTracker } from "@gradio/statustracker";
14
+ import { createEventDispatcher } from "svelte";
15
+ import { BaseFileUpload } from "@gradio/file";
16
+
17
+ export let loading_status: LoadingStatus;
18
+ export let show_label: boolean;
19
+ export let label: string;
20
+ export let root: string;
21
+ export let elem_id = "";
22
+ export let elem_classes: string[] = [];
23
+ export let visible = true;
24
+ export let value: GalleryData[] | null = null;
25
+ export let file_types: string[] | null = ["image", "video", "audio"];
26
+ export let container = true;
27
+ export let scale: number | null = null;
28
+ export let min_width: number | undefined = undefined;
29
+ export let columns: number | number[] | undefined = [2];
30
+ export let rows: number | number[] | undefined = undefined;
31
+ export let height: number | "auto" = "auto";
32
+ export let preview: boolean;
33
+ export let allow_preview = true;
34
+ export let selected_index: number | null = null;
35
+ export let object_fit: "contain" | "cover" | "fill" | "none" | "scale-down" =
36
+ "cover";
37
+ export let show_share_button = false;
38
+ export let interactive: boolean;
39
+ export let show_download_button = false;
40
+ export let gradio: Gradio<{
41
+ change: typeof value;
42
+ upload: typeof value;
43
+ select: SelectData;
44
+ share: ShareData;
45
+ error: string;
46
+ prop_change: Record<string, any>;
47
+ clear_status: LoadingStatus;
48
+ preview_open: never;
49
+ preview_close: never;
50
+ }>;
51
+ export let show_fullscreen_button = true;
52
+ export let fullscreen = false;
53
+
54
+ const dispatch = createEventDispatcher();
55
+
56
+ $: no_value = value === null ? true : value.length === 0;
57
+ $: selected_index, dispatch("prop_change", { selected_index });
58
+
59
+ // Audio file extensions for detection
60
+ const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.flac', '.aac'];
61
+
62
+ function isAudioFile(file: FileData): boolean {
63
+ if (file.mime_type?.includes("audio")) return true;
64
+ const ext = (file.orig_name || file.path || "").toLowerCase();
65
+ return AUDIO_EXTENSIONS.some(e => ext.endsWith(e));
66
+ }
67
+
68
+ async function process_upload_files(
69
+ files: FileData[]
70
+ ): Promise<GalleryData[]> {
71
+ const processed_files = await Promise.all(
72
+ files.map(async (x) => {
73
+ if (x.path?.toLowerCase().endsWith(".svg") && x.url) {
74
+ const response = await fetch(x.url);
75
+ const svgContent = await response.text();
76
+ return {
77
+ ...x,
78
+ url: `data:image/svg+xml,${encodeURIComponent(svgContent)}`
79
+ };
80
+ }
81
+ return x;
82
+ })
83
+ );
84
+
85
+ return processed_files.map((x): GalleryData => {
86
+ if (x.mime_type?.includes("video")) {
87
+ return { video: x, caption: null };
88
+ } else if (isAudioFile(x)) {
89
+ return { audio: x, caption: null };
90
+ } else {
91
+ return { image: x, caption: null };
92
+ }
93
+ });
94
+ }
95
+
96
+ // Handle adding more files to existing gallery
97
+ async function handle_upload(e: CustomEvent<FileData | FileData[]>) {
98
+ const files = Array.isArray(e.detail) ? e.detail : [e.detail];
99
+ const new_items = await process_upload_files(files);
100
+
101
+ // Append to existing items instead of replacing
102
+ if (value && value.length > 0) {
103
+ value = [...value, ...new_items];
104
+ } else {
105
+ value = new_items;
106
+ }
107
+
108
+ gradio.dispatch("upload", value);
109
+ gradio.dispatch("change", value);
110
+ }
111
+ </script>
112
+
113
+ <Block
114
+ {visible}
115
+ variant="solid"
116
+ padding={false}
117
+ {elem_id}
118
+ {elem_classes}
119
+ {container}
120
+ {scale}
121
+ {min_width}
122
+ allow_overflow={false}
123
+ height={typeof height === "number" ? height : undefined}
124
+ bind:fullscreen
125
+ >
126
+ <StatusTracker
127
+ autoscroll={gradio.autoscroll}
128
+ i18n={gradio.i18n}
129
+ {...loading_status}
130
+ on:clear_status={() => gradio.dispatch("clear_status", loading_status)}
131
+ />
132
+ {#if interactive && no_value}
133
+ <!-- Initial upload area when gallery is empty -->
134
+ <BaseFileUpload
135
+ value={null}
136
+ {root}
137
+ {label}
138
+ {file_types}
139
+ max_file_size={gradio.max_file_size}
140
+ file_count={"multiple"}
141
+ i18n={gradio.i18n}
142
+ upload={(...args) => gradio.client.upload(...args)}
143
+ stream_handler={(...args) => gradio.client.stream(...args)}
144
+ on:upload={handle_upload}
145
+ on:error={({ detail }) => {
146
+ loading_status = loading_status || {};
147
+ loading_status.status = "error";
148
+ gradio.dispatch("error", detail);
149
+ }}
150
+ >
151
+ <UploadText i18n={gradio.i18n} type="gallery" />
152
+ </BaseFileUpload>
153
+ {:else}
154
+ <Gallery
155
+ on:change={() => gradio.dispatch("change", value)}
156
+ on:select={(e) => gradio.dispatch("select", e.detail)}
157
+ on:share={(e) => gradio.dispatch("share", e.detail)}
158
+ on:error={(e) => gradio.dispatch("error", e.detail)}
159
+ on:preview_open={() => gradio.dispatch("preview_open")}
160
+ on:preview_close={() => gradio.dispatch("preview_close")}
161
+ on:fullscreen={({ detail }) => {
162
+ fullscreen = detail;
163
+ }}
164
+ on:upload={handle_upload}
165
+ {label}
166
+ {show_label}
167
+ {columns}
168
+ {rows}
169
+ {height}
170
+ {preview}
171
+ {object_fit}
172
+ {interactive}
173
+ {allow_preview}
174
+ bind:selected_index
175
+ bind:value
176
+ {show_share_button}
177
+ {show_download_button}
178
+ i18n={gradio.i18n}
179
+ _fetch={(...args) => gradio.client.fetch(...args)}
180
+ {show_fullscreen_button}
181
+ {fullscreen}
182
+ {root}
183
+ {file_types}
184
+ max_file_size={gradio.max_file_size}
185
+ upload={(...args) => gradio.client.upload(...args)}
186
+ stream_handler={(...args) => gradio.client.stream(...args)}
187
+ />
188
+ {/if}
189
+ </Block>
190
+
191
+ <style>
192
+ /* Component styles are in Gallery.svelte */
193
+ </style>
mediagallery/frontend/gradio.config.js ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: [],
3
+ svelte: {
4
+ preprocess: [],
5
+ },
6
+ build: {
7
+ target: "modules",
8
+ },
9
+ };
mediagallery/frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
mediagallery/frontend/package.json ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "gradio_mediagallery",
3
+ "version": "0.15.24",
4
+ "description": "Gradio UI packages",
5
+ "type": "module",
6
+ "author": "",
7
+ "license": "ISC",
8
+ "private": false,
9
+ "dependencies": {
10
+ "@gradio/atoms": "0.16.2",
11
+ "@gradio/client": "1.15.3",
12
+ "@gradio/icons": "0.12.0",
13
+ "@gradio/image": "0.22.10",
14
+ "@gradio/statustracker": "0.10.13",
15
+ "@gradio/upload": "0.16.8",
16
+ "@gradio/utils": "0.10.2",
17
+ "@gradio/video": "0.14.18",
18
+ "@gradio/file": "0.12.21",
19
+ "dequal": "^2.0.2"
20
+ },
21
+ "devDependencies": {
22
+ "@gradio/preview": "0.13.2"
23
+ },
24
+ "main": "./Index.svelte",
25
+ "main_changeset": true,
26
+ "exports": {
27
+ ".": {
28
+ "gradio": "./Index.svelte",
29
+ "svelte": "./dist/Index.svelte",
30
+ "types": "./dist/Index.svelte.d.ts"
31
+ },
32
+ "./package.json": "./package.json",
33
+ "./base": {
34
+ "gradio": "./shared/Gallery.svelte",
35
+ "svelte": "./dist/shared/Gallery.svelte",
36
+ "types": "./dist/shared/Gallery.svelte.d.ts"
37
+ },
38
+ "./example": {
39
+ "gradio": "./Example.svelte",
40
+ "svelte": "./dist/Example.svelte",
41
+ "types": "./dist/Example.svelte.d.ts"
42
+ }
43
+ },
44
+ "peerDependencies": {
45
+ "svelte": "^4.0.0"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/gradio-app/gradio.git",
50
+ "directory": "js/gallery"
51
+ }
52
+ }
mediagallery/frontend/shared/Gallery.svelte ADDED
@@ -0,0 +1,1030 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import {
3
+ BlockLabel,
4
+ Empty,
5
+ ShareButton,
6
+ IconButton,
7
+ IconButtonWrapper,
8
+ FullscreenButton
9
+ } from "@gradio/atoms";
10
+ import { Upload } from "@gradio/upload";
11
+ import type { SelectData, I18nFormatter } from "@gradio/utils";
12
+ import { Image } from "@gradio/image/shared";
13
+ import { Video } from "@gradio/video/shared";
14
+ import { dequal } from "dequal";
15
+ import { createEventDispatcher, onMount } from "svelte";
16
+ import { tick } from "svelte";
17
+ import type { GalleryImage, GalleryVideo, GalleryAudio, GalleryData } from "../types";
18
+ import { getMediaType, getMediaFile } from "../types";
19
+
20
+ import { Download, Image as ImageIcon, Clear, Play } from "@gradio/icons";
21
+ import { FileData } from "@gradio/client";
22
+ import type { Client } from "@gradio/client";
23
+ import { format_gallery_for_sharing } from "./utils";
24
+
25
+ export let show_label = true;
26
+ export let label: string;
27
+ export let value: GalleryData[] | null = null;
28
+ export let columns: number | number[] | undefined = [2];
29
+ export let rows: number | number[] | undefined = undefined;
30
+ export let height: number | "auto" = "auto";
31
+ export let preview: boolean;
32
+ export let allow_preview = true;
33
+ export let object_fit: "contain" | "cover" | "fill" | "none" | "scale-down" =
34
+ "cover";
35
+ export let show_share_button = false;
36
+ export let show_download_button = false;
37
+ export let i18n: I18nFormatter;
38
+ export let selected_index: number | null = null;
39
+ export let interactive: boolean;
40
+ export let _fetch: typeof fetch;
41
+ export let mode: "normal" | "minimal" = "normal";
42
+ export let show_fullscreen_button = true;
43
+ export let display_icon_button_wrapper_top_corner = false;
44
+ export let fullscreen = false;
45
+ export let root = "";
46
+ export let file_types: string[] | null = ["image", "video", "audio"];
47
+ export let max_file_size: number | null = null;
48
+ export let upload: Client["upload"] | undefined = undefined;
49
+ export let stream_handler: Client["stream"] | undefined = undefined;
50
+
51
+ let is_full_screen = false;
52
+ let image_container: HTMLElement;
53
+
54
+ const dispatch = createEventDispatcher<{
55
+ change: undefined;
56
+ select: SelectData;
57
+ preview_open: undefined;
58
+ preview_close: undefined;
59
+ fullscreen: boolean;
60
+ upload: FileData | FileData[];
61
+ error: string;
62
+ }>();
63
+
64
+ // tracks whether the value of the gallery was reset
65
+ let was_reset = true;
66
+
67
+ $: was_reset = value == null || value.length === 0 ? true : was_reset;
68
+
69
+ let resolved_value: GalleryData[] | null = null;
70
+
71
+ $: resolved_value =
72
+ value == null
73
+ ? null
74
+ : (value.map((data) => {
75
+ if ("video" in data) {
76
+ return {
77
+ video: data.video as FileData,
78
+ caption: data.caption
79
+ };
80
+ } else if ("audio" in data) {
81
+ return {
82
+ audio: data.audio as FileData,
83
+ caption: data.caption
84
+ };
85
+ } else if ("image" in data) {
86
+ return { image: data.image as FileData, caption: data.caption };
87
+ }
88
+ return {};
89
+ }) as GalleryData[]);
90
+
91
+ let prev_value: GalleryData[] | null = value;
92
+ if (selected_index == null && preview && value?.length) {
93
+ selected_index = 0;
94
+ }
95
+ let old_selected_index: number | null = selected_index;
96
+
97
+ $: if (!dequal(prev_value, value)) {
98
+ // When value is falsy (clear button or first load),
99
+ // preview determines the selected image
100
+ if (was_reset) {
101
+ selected_index = preview && value?.length ? 0 : null;
102
+ was_reset = false;
103
+ // Otherwise we keep the selected_index the same if the
104
+ // gallery has at least as many elements as it did before
105
+ } else {
106
+ if (selected_index !== null && value !== null) {
107
+ selected_index = Math.max(
108
+ 0,
109
+ Math.min(selected_index, value.length - 1)
110
+ );
111
+ } else {
112
+ selected_index = null;
113
+ }
114
+ }
115
+ dispatch("change");
116
+ prev_value = value;
117
+ }
118
+
119
+ $: previous =
120
+ ((selected_index ?? 0) + (resolved_value?.length ?? 0) - 1) %
121
+ (resolved_value?.length ?? 0);
122
+ $: next = ((selected_index ?? 0) + 1) % (resolved_value?.length ?? 0);
123
+
124
+ function handle_preview_click(event: MouseEvent): void {
125
+ const element = event.target as HTMLElement;
126
+ const x = event.offsetX;
127
+ const width = element.offsetWidth;
128
+ const centerX = width / 2;
129
+
130
+ if (x < centerX) {
131
+ selected_index = previous;
132
+ } else {
133
+ selected_index = next;
134
+ }
135
+ }
136
+
137
+ function on_keydown(e: KeyboardEvent): void {
138
+ switch (e.code) {
139
+ case "Escape":
140
+ e.preventDefault();
141
+ selected_index = null;
142
+ break;
143
+ case "ArrowLeft":
144
+ e.preventDefault();
145
+ selected_index = previous;
146
+ break;
147
+ case "ArrowRight":
148
+ e.preventDefault();
149
+ selected_index = next;
150
+ break;
151
+ default:
152
+ break;
153
+ }
154
+ }
155
+
156
+ $: {
157
+ if (selected_index !== old_selected_index) {
158
+ old_selected_index = selected_index;
159
+ if (selected_index !== null) {
160
+ if (resolved_value != null) {
161
+ selected_index = Math.max(
162
+ 0,
163
+ Math.min(selected_index, resolved_value.length - 1)
164
+ );
165
+ }
166
+ dispatch("select", {
167
+ index: selected_index,
168
+ value: resolved_value?.[selected_index]
169
+ });
170
+ }
171
+ }
172
+ }
173
+
174
+ $: if (allow_preview) {
175
+ scroll_to_img(selected_index);
176
+ }
177
+
178
+ let el: HTMLButtonElement[] = [];
179
+ let container_element: HTMLDivElement;
180
+
181
+ async function scroll_to_img(index: number | null): Promise<void> {
182
+ if (typeof index !== "number") return;
183
+ await tick();
184
+
185
+ if (el[index] === undefined) return;
186
+
187
+ el[index]?.focus();
188
+
189
+ const { left: container_left, width: container_width } =
190
+ container_element.getBoundingClientRect();
191
+ const { left, width } = el[index].getBoundingClientRect();
192
+
193
+ const relative_left = left - container_left;
194
+
195
+ const pos =
196
+ relative_left +
197
+ width / 2 -
198
+ container_width / 2 +
199
+ container_element.scrollLeft;
200
+
201
+ if (container_element && typeof container_element.scrollTo === "function") {
202
+ container_element.scrollTo({
203
+ left: pos < 0 ? 0 : pos,
204
+ behavior: "smooth"
205
+ });
206
+ }
207
+ }
208
+
209
+ let window_height = 0;
210
+
211
+ // Unlike `gr.Image()`, images specified via remote URLs are not cached in the server
212
+ // and their remote URLs are directly passed to the client as `value[].image.url`.
213
+ // The `download` attribute of the <a> tag doesn't work for remote URLs (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#download),
214
+ // so we need to download the image via JS as below.
215
+ async function download(file_url: string, name: string): Promise<void> {
216
+ let response;
217
+ try {
218
+ response = await _fetch(file_url);
219
+ } catch (error) {
220
+ if (error instanceof TypeError) {
221
+ // If CORS is not allowed (https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#checking_that_the_fetch_was_successful),
222
+ // open the link in a new tab instead, mimicing the behavior of the `download` attribute for remote URLs,
223
+ // which is not ideal, but a reasonable fallback.
224
+ window.open(file_url, "_blank", "noreferrer");
225
+ return;
226
+ }
227
+
228
+ throw error;
229
+ }
230
+ const blob = await response.blob();
231
+ const url = URL.createObjectURL(blob);
232
+ const link = document.createElement("a");
233
+ link.href = url;
234
+ link.download = name;
235
+ link.click();
236
+ URL.revokeObjectURL(url);
237
+ }
238
+
239
+ $: selected_media =
240
+ selected_index != null && resolved_value != null
241
+ ? resolved_value[selected_index]
242
+ : null;
243
+
244
+ let thumbnails_overflow = false;
245
+
246
+ function check_thumbnails_overflow(): void {
247
+ if (container_element) {
248
+ thumbnails_overflow =
249
+ container_element.scrollWidth > container_element.clientWidth;
250
+ }
251
+ }
252
+
253
+ onMount(() => {
254
+ check_thumbnails_overflow();
255
+ document.addEventListener("fullscreenchange", () => {
256
+ is_full_screen = !!document.fullscreenElement;
257
+ });
258
+ window.addEventListener("resize", check_thumbnails_overflow);
259
+ return () =>
260
+ window.removeEventListener("resize", check_thumbnails_overflow);
261
+ });
262
+
263
+ $: resolved_value, check_thumbnails_overflow();
264
+ $: if (container_element) {
265
+ check_thumbnails_overflow();
266
+ }
267
+ </script>
268
+
269
+ <svelte:window bind:innerHeight={window_height} />
270
+
271
+ {#if show_label}
272
+ <BlockLabel {show_label} Icon={ImageIcon} label={label || "Gallery"} />
273
+ {/if}
274
+ {#if value == null || resolved_value == null || resolved_value.length === 0}
275
+ <Empty unpadded_box={true} size="large"><ImageIcon /></Empty>
276
+ {:else}
277
+ <div class="gallery-container" bind:this={image_container}>
278
+ {#if selected_media && allow_preview}
279
+ <button
280
+ on:keydown={on_keydown}
281
+ class="preview"
282
+ class:minimal={mode === "minimal"}
283
+ >
284
+ <IconButtonWrapper
285
+ display_top_corner={display_icon_button_wrapper_top_corner}
286
+ >
287
+ {#if show_download_button}
288
+ <IconButton
289
+ Icon={Download}
290
+ label={i18n("common.download")}
291
+ on:click={() => {
292
+ const file = getMediaFile(selected_media);
293
+ if (file == null) {
294
+ return;
295
+ }
296
+ const { url, orig_name } = file;
297
+ if (url) {
298
+ download(url, orig_name ?? "media");
299
+ }
300
+ }}
301
+ />
302
+ {/if}
303
+
304
+ {#if show_fullscreen_button}
305
+ <FullscreenButton {fullscreen} on:fullscreen />
306
+ {/if}
307
+
308
+ {#if show_share_button}
309
+ <div class="icon-button">
310
+ <ShareButton
311
+ {i18n}
312
+ on:share
313
+ on:error
314
+ value={resolved_value}
315
+ formatter={format_gallery_for_sharing}
316
+ />
317
+ </div>
318
+ {/if}
319
+ {#if !is_full_screen}
320
+ <IconButton
321
+ Icon={Clear}
322
+ label="Close"
323
+ on:click={() => {
324
+ selected_index = null;
325
+ dispatch("preview_close");
326
+ }}
327
+ />
328
+ {/if}
329
+ </IconButtonWrapper>
330
+ <button
331
+ class="media-button"
332
+ on:click={"image" in selected_media
333
+ ? (event) => handle_preview_click(event)
334
+ : null}
335
+ style="height: calc(100% - {selected_media.caption
336
+ ? '80px'
337
+ : '60px'})"
338
+ aria-label="detailed view of selected media"
339
+ >
340
+ {#if "image" in selected_media}
341
+ <Image
342
+ data-testid="detailed-image"
343
+ src={selected_media.image.url}
344
+ alt={selected_media.caption || ""}
345
+ title={selected_media.caption || null}
346
+ class={selected_media.caption && "with-caption"}
347
+ loading="lazy"
348
+ />
349
+ {:else if "video" in selected_media}
350
+ <Video
351
+ src={selected_media.video.url}
352
+ data-testid={"detailed-video"}
353
+ alt={selected_media.caption || ""}
354
+ loading="lazy"
355
+ loop={false}
356
+ is_stream={false}
357
+ muted={false}
358
+ controls={true}
359
+ />
360
+ {:else if "audio" in selected_media}
361
+ <div class="audio-preview">
362
+ <div class="audio-icon-large">
363
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
364
+ <path d="M9 18V5l12-2v13"></path>
365
+ <circle cx="6" cy="18" r="3"></circle>
366
+ <circle cx="18" cy="16" r="3"></circle>
367
+ </svg>
368
+ </div>
369
+ <div class="audio-filename">{selected_media.audio.orig_name || "Audio"}</div>
370
+ <audio
371
+ src={selected_media.audio.url}
372
+ controls
373
+ class="audio-player"
374
+ data-testid="detailed-audio"
375
+ />
376
+ </div>
377
+ {/if}
378
+ </button>
379
+ {#if selected_media?.caption}
380
+ <caption class="caption">
381
+ {selected_media.caption}
382
+ </caption>
383
+ {/if}
384
+ <div
385
+ bind:this={container_element}
386
+ class="thumbnails scroll-hide"
387
+ data-testid="container_el"
388
+ style="justify-content: {thumbnails_overflow
389
+ ? 'flex-start'
390
+ : 'center'};"
391
+ >
392
+ {#each resolved_value as media, i}
393
+ <button
394
+ bind:this={el[i]}
395
+ on:click={() => (selected_index = i)}
396
+ class="thumbnail-item thumbnail-small"
397
+ class:selected={selected_index === i && mode !== "minimal"}
398
+ aria-label={"Thumbnail " +
399
+ (i + 1) +
400
+ " of " +
401
+ resolved_value.length}
402
+ >
403
+ {#if "image" in media}
404
+ <Image
405
+ src={media.image.url}
406
+ title={media.caption || null}
407
+ data-testid={"thumbnail " + (i + 1)}
408
+ alt=""
409
+ loading="lazy"
410
+ />
411
+ {:else if "video" in media}
412
+ <Play />
413
+ <Video
414
+ src={media.video.url}
415
+ title={media.caption || null}
416
+ is_stream={false}
417
+ data-testid={"thumbnail " + (i + 1)}
418
+ alt=""
419
+ loading="lazy"
420
+ loop={false}
421
+ />
422
+ {:else if "audio" in media}
423
+ <div class="audio-thumbnail">
424
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
425
+ <path d="M9 18V5l12-2v13"></path>
426
+ <circle cx="6" cy="18" r="3"></circle>
427
+ <circle cx="18" cy="16" r="3"></circle>
428
+ </svg>
429
+ </div>
430
+ {/if}
431
+ </button>
432
+ {/each}
433
+ </div>
434
+ </button>
435
+ {/if}
436
+
437
+ <div
438
+ class="grid-wrap"
439
+ class:minimal={mode === "minimal"}
440
+ class:fixed-height={mode !== "minimal" && (!height || height == "auto")}
441
+ class:hidden={is_full_screen}
442
+ >
443
+ <div
444
+ class="grid-container"
445
+ style="--grid-cols:{columns}; --grid-rows:{rows}; --object-fit: {object_fit}; height: {height};"
446
+ class:pt-6={show_label}
447
+ >
448
+ {#each resolved_value as entry, i}
449
+ <div class="thumbnail-wrapper">
450
+ <button
451
+ class="thumbnail-item thumbnail-lg"
452
+ class:selected={selected_index === i}
453
+ on:click={() => {
454
+ if (selected_index === null && allow_preview) {
455
+ dispatch("preview_open");
456
+ }
457
+ selected_index = i;
458
+ }}
459
+ aria-label={"Thumbnail " + (i + 1) + " of " + resolved_value.length}
460
+ >
461
+ {#if "image" in entry}
462
+ <Image
463
+ alt={entry.caption || ""}
464
+ src={typeof entry.image === "string"
465
+ ? entry.image
466
+ : entry.image.url}
467
+ loading="lazy"
468
+ />
469
+ <div class="media-type-badge image">
470
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
471
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
472
+ <circle cx="8.5" cy="8.5" r="1.5"></circle>
473
+ <polyline points="21 15 16 10 5 21"></polyline>
474
+ </svg>
475
+ </div>
476
+ {:else if "video" in entry}
477
+ <Play />
478
+ <Video
479
+ src={entry.video.url}
480
+ title={entry.caption || null}
481
+ is_stream={false}
482
+ data-testid={"thumbnail " + (i + 1)}
483
+ alt=""
484
+ loading="lazy"
485
+ loop={false}
486
+ />
487
+ <div class="media-type-badge video">
488
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
489
+ <polygon points="5 3 19 12 5 21 5 3"></polygon>
490
+ </svg>
491
+ </div>
492
+ {:else if "audio" in entry}
493
+ <div class="audio-thumbnail-lg">
494
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
495
+ <path d="M9 18V5l12-2v13"></path>
496
+ <circle cx="6" cy="18" r="3"></circle>
497
+ <circle cx="18" cy="16" r="3"></circle>
498
+ </svg>
499
+ </div>
500
+ <div class="media-type-badge audio">
501
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
502
+ <path d="M9 18V5l12-2v13"></path>
503
+ <circle cx="6" cy="18" r="3"></circle>
504
+ <circle cx="18" cy="16" r="3"></circle>
505
+ </svg>
506
+ </div>
507
+ {/if}
508
+ </button>
509
+ <!-- Remove button -->
510
+ {#if interactive}
511
+ <button
512
+ class="remove-btn"
513
+ on:click|stopPropagation={() => {
514
+ if (value) {
515
+ value = value.filter((_, idx) => idx !== i);
516
+ if (selected_index !== null && selected_index >= i) {
517
+ selected_index = selected_index > 0 ? selected_index - 1 : null;
518
+ }
519
+ dispatch("change");
520
+ }
521
+ }}
522
+ aria-label="Remove item"
523
+ >
524
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
525
+ <line x1="18" y1="6" x2="6" y2="18"></line>
526
+ <line x1="6" y1="6" x2="18" y2="18"></line>
527
+ </svg>
528
+ </button>
529
+ {/if}
530
+ <!-- Filename label -->
531
+ <div class="filename-label">
532
+ {#if "image" in entry}
533
+ {entry.image.orig_name || "Image"}
534
+ {:else if "video" in entry}
535
+ {entry.video.orig_name || "Video"}
536
+ {:else if "audio" in entry}
537
+ {entry.audio.orig_name || "Audio"}
538
+ {/if}
539
+ </div>
540
+ </div>
541
+ {/each}
542
+ </div>
543
+ </div>
544
+ <!-- Add Media button below grid -->
545
+ {#if interactive && upload && stream_handler}
546
+ <div class="add-media-bar">
547
+ <Upload
548
+ filetype={file_types}
549
+ file_count="multiple"
550
+ {max_file_size}
551
+ {root}
552
+ {upload}
553
+ {stream_handler}
554
+ on:load={(e) => dispatch("upload", e.detail)}
555
+ on:error={(e) => dispatch("error", e.detail)}
556
+ >
557
+ <div class="add-media-btn">
558
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
559
+ <line x1="12" y1="5" x2="12" y2="19"></line>
560
+ <line x1="5" y1="12" x2="19" y2="12"></line>
561
+ </svg>
562
+ <span>Add Media</span>
563
+ </div>
564
+ </Upload>
565
+ </div>
566
+ {/if}
567
+ </div>
568
+ {/if}
569
+
570
+ <style lang="postcss">
571
+ .gallery-container {
572
+ position: relative;
573
+ display: flex;
574
+ flex-direction: column;
575
+ width: 100%;
576
+ height: 100%;
577
+ min-height: 0;
578
+ overflow: hidden;
579
+ }
580
+
581
+ .image-container {
582
+ height: 100%;
583
+ position: relative;
584
+ }
585
+ .image-container :global(img),
586
+ button {
587
+ width: var(--size-full);
588
+ height: var(--size-full);
589
+ object-fit: contain;
590
+ display: block;
591
+ border-radius: var(--radius-lg);
592
+ }
593
+
594
+ .preview {
595
+ display: flex;
596
+ position: absolute;
597
+ flex-direction: column;
598
+ z-index: var(--layer-2);
599
+ border-radius: calc(var(--block-radius) - var(--block-border-width));
600
+ -webkit-backdrop-filter: blur(8px);
601
+ backdrop-filter: blur(8px);
602
+ width: var(--size-full);
603
+ height: var(--size-full);
604
+ }
605
+
606
+ .preview.minimal {
607
+ width: fit-content;
608
+ height: fit-content;
609
+ }
610
+
611
+ .preview::before {
612
+ content: "";
613
+ position: absolute;
614
+ z-index: var(--layer-below);
615
+ background: var(--background-fill-primary);
616
+ opacity: 0.9;
617
+ width: var(--size-full);
618
+ height: var(--size-full);
619
+ }
620
+
621
+ .fixed-height {
622
+ min-height: var(--size-80);
623
+ max-height: 55vh;
624
+ }
625
+
626
+ @media (--screen-xl) {
627
+ .fixed-height {
628
+ min-height: 450px;
629
+ }
630
+ }
631
+
632
+ .media-button {
633
+ height: calc(100% - 60px);
634
+ width: 100%;
635
+ display: flex;
636
+ }
637
+ .media-button :global(img),
638
+ .media-button :global(video) {
639
+ width: var(--size-full);
640
+ height: var(--size-full);
641
+ object-fit: contain;
642
+ }
643
+ .thumbnails :global(img) {
644
+ object-fit: cover;
645
+ width: var(--size-full);
646
+ height: var(--size-full);
647
+ }
648
+ .thumbnails :global(svg) {
649
+ position: absolute;
650
+ top: var(--size-2);
651
+ left: var(--size-2);
652
+ width: 50%;
653
+ height: 50%;
654
+ opacity: 50%;
655
+ }
656
+ .preview :global(img.with-caption) {
657
+ height: var(--size-full);
658
+ }
659
+
660
+ .preview.minimal :global(img.with-caption) {
661
+ height: auto;
662
+ }
663
+
664
+ .selectable {
665
+ cursor: crosshair;
666
+ }
667
+
668
+ .caption {
669
+ padding: var(--size-2) var(--size-3);
670
+ overflow: hidden;
671
+ color: var(--block-label-text-color);
672
+ font-weight: var(--weight-semibold);
673
+ text-align: center;
674
+ text-overflow: ellipsis;
675
+ white-space: nowrap;
676
+ align-self: center;
677
+ }
678
+
679
+ .thumbnails {
680
+ display: flex;
681
+ position: absolute;
682
+ bottom: 0;
683
+ justify-content: flex-start;
684
+ align-items: center;
685
+ gap: var(--spacing-lg);
686
+ width: var(--size-full);
687
+ height: var(--size-14);
688
+ overflow-x: scroll;
689
+ }
690
+
691
+ .thumbnail-item {
692
+ --ring-color: transparent;
693
+ position: relative;
694
+ box-shadow:
695
+ inset 0 0 0 1px var(--ring-color),
696
+ var(--shadow-drop);
697
+ border: 1px solid var(--border-color-primary);
698
+ border-radius: var(--button-small-radius);
699
+ background: var(--background-fill-secondary);
700
+ aspect-ratio: var(--ratio-square);
701
+ width: var(--size-full);
702
+ height: var(--size-full);
703
+ overflow: clip;
704
+ }
705
+
706
+ .thumbnail-item:hover {
707
+ --ring-color: var(--color-accent);
708
+ border-color: var(--color-accent);
709
+ filter: brightness(1.1);
710
+ }
711
+
712
+ .thumbnail-item.selected {
713
+ --ring-color: var(--color-accent);
714
+ border-color: var(--color-accent);
715
+ }
716
+
717
+ .thumbnail-item :global(svg) {
718
+ position: absolute;
719
+ top: 50%;
720
+ left: 50%;
721
+ width: 50%;
722
+ height: 50%;
723
+ opacity: 50%;
724
+ transform: translate(-50%, -50%);
725
+ }
726
+
727
+ .thumbnail-item :global(video) {
728
+ width: var(--size-full);
729
+ height: var(--size-full);
730
+ overflow: hidden;
731
+ object-fit: cover;
732
+ }
733
+
734
+ .thumbnail-small {
735
+ flex: none;
736
+ transform: scale(0.9);
737
+ transition: 0.075s;
738
+ width: var(--size-9);
739
+ height: var(--size-9);
740
+ }
741
+ .thumbnail-small.selected {
742
+ --ring-color: var(--color-accent);
743
+ transform: scale(1);
744
+ border-color: var(--color-accent);
745
+ }
746
+
747
+ .thumbnail-small > img {
748
+ width: var(--size-full);
749
+ height: var(--size-full);
750
+ overflow: hidden;
751
+ object-fit: var(--object-fit);
752
+ }
753
+
754
+ .grid-wrap {
755
+ position: relative;
756
+ padding: var(--size-2);
757
+ max-height: 100%;
758
+ overflow-y: auto;
759
+ overflow-x: hidden;
760
+ flex: 1;
761
+ }
762
+
763
+ .grid-container {
764
+ display: grid;
765
+ position: relative;
766
+ grid-template-columns: repeat(2, minmax(0, 1fr));
767
+ gap: var(--spacing-md);
768
+ width: 100%;
769
+ }
770
+
771
+ /* Responsive columns */
772
+ @media (min-width: 768px) {
773
+ .grid-container {
774
+ grid-template-columns: repeat(3, minmax(0, 1fr));
775
+ }
776
+ }
777
+
778
+ @media (min-width: 1280px) {
779
+ .grid-container {
780
+ grid-template-columns: repeat(4, minmax(0, 1fr));
781
+ }
782
+ }
783
+
784
+ @media (min-width: 1536px) {
785
+ .grid-container {
786
+ grid-template-columns: repeat(5, minmax(0, 1fr));
787
+ }
788
+ }
789
+
790
+ .thumbnail-wrapper {
791
+ position: relative;
792
+ width: 100%;
793
+ padding-bottom: 100%; /* Square aspect ratio using padding trick */
794
+ min-width: 0;
795
+ overflow: hidden;
796
+ border-radius: var(--button-small-radius);
797
+ background: var(--background-fill-secondary);
798
+ aspect-ratio: 1 / 1;
799
+ }
800
+
801
+ .thumbnail-lg {
802
+ position: absolute;
803
+ top: 0;
804
+ left: 0;
805
+ width: 100%;
806
+ height: 100%;
807
+ overflow: hidden;
808
+ padding: 0;
809
+ margin: 0;
810
+ border: none;
811
+ background: transparent;
812
+ cursor: pointer;
813
+ }
814
+
815
+ /* Force all images and videos to be square cropped */
816
+ .thumbnail-lg :global(img),
817
+ .thumbnail-lg :global(video) {
818
+ position: absolute !important;
819
+ top: 0 !important;
820
+ left: 0 !important;
821
+ width: 100% !important;
822
+ height: 100% !important;
823
+ object-fit: cover !important;
824
+ max-width: none !important;
825
+ max-height: none !important;
826
+ min-width: 100% !important;
827
+ min-height: 100% !important;
828
+ }
829
+
830
+ /* Picture elements from Image component */
831
+ .thumbnail-lg :global(picture) {
832
+ position: absolute !important;
833
+ top: 0 !important;
834
+ left: 0 !important;
835
+ width: 100% !important;
836
+ height: 100% !important;
837
+ overflow: hidden !important;
838
+ }
839
+
840
+ .grid-wrap.minimal {
841
+ padding: 0;
842
+ }
843
+
844
+ /* Audio thumbnail styles */
845
+ .audio-thumbnail {
846
+ display: flex;
847
+ align-items: center;
848
+ justify-content: center;
849
+ width: 100%;
850
+ height: 100%;
851
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
852
+ color: white;
853
+ }
854
+
855
+ .audio-thumbnail-lg {
856
+ position: absolute;
857
+ top: 0;
858
+ left: 0;
859
+ display: flex;
860
+ flex-direction: column;
861
+ align-items: center;
862
+ justify-content: center;
863
+ width: 100%;
864
+ height: 100%;
865
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
866
+ color: white;
867
+ gap: var(--size-2);
868
+ padding: var(--size-2);
869
+ }
870
+
871
+ .audio-thumbnail-lg .audio-name {
872
+ font-size: var(--text-sm);
873
+ text-overflow: ellipsis;
874
+ overflow: hidden;
875
+ white-space: nowrap;
876
+ max-width: 100%;
877
+ text-align: center;
878
+ }
879
+
880
+ /* Audio preview styles */
881
+ .audio-preview {
882
+ display: flex;
883
+ flex-direction: column;
884
+ align-items: center;
885
+ justify-content: center;
886
+ width: 100%;
887
+ height: 100%;
888
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
889
+ color: white;
890
+ gap: var(--size-4);
891
+ padding: var(--size-8);
892
+ }
893
+
894
+ .audio-icon-large {
895
+ opacity: 0.8;
896
+ }
897
+
898
+ .audio-filename {
899
+ font-size: var(--text-lg);
900
+ font-weight: var(--weight-semibold);
901
+ text-align: center;
902
+ max-width: 80%;
903
+ overflow: hidden;
904
+ text-overflow: ellipsis;
905
+ white-space: nowrap;
906
+ }
907
+
908
+ .audio-player {
909
+ width: 80%;
910
+ max-width: 400px;
911
+ }
912
+
913
+ /* Media type badge styles */
914
+ .media-type-badge {
915
+ position: absolute;
916
+ bottom: var(--size-1);
917
+ left: var(--size-1);
918
+ display: flex;
919
+ align-items: center;
920
+ justify-content: center;
921
+ padding: var(--size-1);
922
+ border-radius: var(--radius-sm);
923
+ background: rgba(0, 0, 0, 0.6);
924
+ color: white;
925
+ z-index: var(--layer-1);
926
+ }
927
+
928
+ .media-type-badge.image {
929
+ background: rgba(59, 130, 246, 0.8);
930
+ }
931
+
932
+ .media-type-badge.video {
933
+ background: rgba(239, 68, 68, 0.8);
934
+ }
935
+
936
+ .media-type-badge.audio {
937
+ background: rgba(139, 92, 246, 0.8);
938
+ }
939
+
940
+ .media-type-badge svg {
941
+ width: 12px;
942
+ height: 12px;
943
+ }
944
+
945
+ /* Remove button */
946
+ .remove-btn {
947
+ position: absolute;
948
+ top: var(--size-1);
949
+ right: var(--size-1);
950
+ display: flex;
951
+ align-items: center;
952
+ justify-content: center;
953
+ width: 24px;
954
+ height: 24px;
955
+ border-radius: 50%;
956
+ background: rgba(0, 0, 0, 0.7);
957
+ color: white;
958
+ border: none;
959
+ cursor: pointer;
960
+ opacity: 0;
961
+ transition: opacity 0.2s ease, background 0.2s ease;
962
+ z-index: var(--layer-2);
963
+ }
964
+
965
+ .thumbnail-wrapper:hover .remove-btn {
966
+ opacity: 1;
967
+ }
968
+
969
+ .remove-btn:hover {
970
+ background: rgba(239, 68, 68, 0.9);
971
+ }
972
+
973
+ .remove-btn svg {
974
+ width: 14px;
975
+ height: 14px;
976
+ }
977
+
978
+ /* Filename label */
979
+ .filename-label {
980
+ position: absolute;
981
+ bottom: 0;
982
+ left: 0;
983
+ right: 0;
984
+ padding: var(--size-1) var(--size-2);
985
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
986
+ color: white;
987
+ font-size: var(--text-xs);
988
+ text-overflow: ellipsis;
989
+ overflow: hidden;
990
+ white-space: nowrap;
991
+ pointer-events: none;
992
+ border-radius: 0 0 var(--button-small-radius) var(--button-small-radius);
993
+ z-index: var(--layer-1);
994
+ }
995
+
996
+ /* Add Media button bar */
997
+ .add-media-bar {
998
+ padding: var(--size-2);
999
+ border-top: 1px solid var(--border-color-primary);
1000
+ }
1001
+
1002
+ .add-media-btn {
1003
+ display: flex;
1004
+ align-items: center;
1005
+ justify-content: center;
1006
+ gap: var(--size-2);
1007
+ padding: var(--size-2) var(--size-4);
1008
+ background: var(--background-fill-secondary);
1009
+ border: 1px dashed var(--border-color-primary);
1010
+ border-radius: var(--radius-lg);
1011
+ color: var(--body-text-color-subdued);
1012
+ cursor: pointer;
1013
+ transition: all 0.2s ease;
1014
+ width: 100%;
1015
+ }
1016
+
1017
+ .add-media-btn:hover {
1018
+ background: var(--background-fill-primary);
1019
+ border-color: var(--color-accent);
1020
+ color: var(--color-accent);
1021
+ }
1022
+
1023
+ .add-media-btn input {
1024
+ display: none;
1025
+ }
1026
+
1027
+ .add-media-btn svg {
1028
+ flex-shrink: 0;
1029
+ }
1030
+ </style>
mediagallery/frontend/shared/utils.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { uploadToHuggingFace } from "@gradio/utils";
2
+ import type { GalleryData } from "../types";
3
+ import { getMediaFile } from "../types";
4
+
5
+ export async function format_gallery_for_sharing(
6
+ value: GalleryData[] | null
7
+ ): Promise<string> {
8
+ if (!value) return "";
9
+ let urls = await Promise.all(
10
+ value.map(async (item) => {
11
+ const file = getMediaFile(item);
12
+ if (!file || !file.url) return "";
13
+ return await uploadToHuggingFace(file.url, "url");
14
+ })
15
+ );
16
+
17
+ return `<div style="display: flex; flex-wrap: wrap; gap: 16px">${urls
18
+ .filter(url => url)
19
+ .map((url) => `<img src="${url}" style="height: 400px" />`)
20
+ .join("")}</div>`;
21
+ }
mediagallery/frontend/tsconfig.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "allowJs": true,
4
+ "checkJs": true,
5
+ "esModuleInterop": true,
6
+ "forceConsistentCasingInFileNames": true,
7
+ "resolveJsonModule": true,
8
+ "skipLibCheck": true,
9
+ "sourceMap": true,
10
+ "strict": true,
11
+ "verbatimModuleSyntax": true
12
+ },
13
+ "exclude": ["node_modules", "dist", "./gradio.config.js"]
14
+ }
mediagallery/frontend/types.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FileData } from "@gradio/client";
2
+
3
+ export interface GalleryImage {
4
+ image: FileData;
5
+ caption: string | null;
6
+ }
7
+
8
+ export interface GalleryVideo {
9
+ video: FileData;
10
+ caption: string | null;
11
+ }
12
+
13
+ export interface GalleryAudio {
14
+ audio: FileData;
15
+ caption: string | null;
16
+ }
17
+
18
+ export type GalleryData = GalleryImage | GalleryVideo | GalleryAudio;
19
+
20
+ // Helper to detect media type
21
+ export type MediaType = "image" | "video" | "audio";
22
+
23
+ export function getMediaType(item: GalleryData): MediaType {
24
+ if ("video" in item) return "video";
25
+ if ("audio" in item) return "audio";
26
+ return "image";
27
+ }
28
+
29
+ export function getMediaFile(item: GalleryData): FileData {
30
+ if ("video" in item) return item.video;
31
+ if ("audio" in item) return item.audio;
32
+ return item.image;
33
+ }
mediagallery/pyproject.toml ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = [
3
+ "hatchling",
4
+ "hatch-requirements-txt",
5
+ "hatch-fancy-pypi-readme>=22.5.0",
6
+ ]
7
+ build-backend = "hatchling.build"
8
+
9
+ [project]
10
+ name = "gradio_mediagallery"
11
+ version = "0.0.1"
12
+ description = "Python library for easily interacting with trained machine learning models"
13
+ readme = "README.md"
14
+ license = "Apache-2.0"
15
+ requires-python = ">=3.8"
16
+ authors = [{ name = "YOUR NAME", email = "YOUREMAIL@domain.com" }]
17
+ keywords = [
18
+ "gradio-custom-component",
19
+ "gradio-template-Gallery"
20
+ ]
21
+ # Add dependencies here
22
+ dependencies = ["gradio>=4.0,<6.0"]
23
+ classifiers = [
24
+ 'Development Status :: 3 - Alpha',
25
+ 'Operating System :: OS Independent',
26
+ 'Programming Language :: Python :: 3',
27
+ 'Programming Language :: Python :: 3 :: Only',
28
+ 'Programming Language :: Python :: 3.8',
29
+ 'Programming Language :: Python :: 3.9',
30
+ 'Programming Language :: Python :: 3.10',
31
+ 'Programming Language :: Python :: 3.11',
32
+ 'Topic :: Scientific/Engineering',
33
+ 'Topic :: Scientific/Engineering :: Artificial Intelligence',
34
+ 'Topic :: Scientific/Engineering :: Visualization',
35
+ ]
36
+
37
+ # The repository and space URLs are optional, but recommended.
38
+ # Adding a repository URL will create a badge in the auto-generated README that links to the repository.
39
+ # Adding a space URL will create a badge in the auto-generated README that links to the space.
40
+ # This will make it easy for people to find your deployed demo or source code when they
41
+ # encounter your project in the wild.
42
+
43
+ # [project.urls]
44
+ # repository = "your github repository"
45
+ # space = "your space url"
46
+
47
+ [project.optional-dependencies]
48
+ dev = ["build", "twine"]
49
+
50
+ [tool.hatch.build]
51
+ artifacts = ["/backend/gradio_mediagallery/templates", "*.pyi"]
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["/backend/gradio_mediagallery"]