Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix the download button of the gr.Gallery() component to work #6487

Merged
merged 26 commits into from Dec 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3b313a0
Fix the download button of the `gr.Gallery()` component to work
whitphx Nov 19, 2023
a6fb06f
Refactoring js/gallery/shared/Gallery.svelte
whitphx Nov 19, 2023
8eff43c
Fix `gr.Gallery()` to set `orig_name` for URLs
whitphx Nov 19, 2023
776a7cd
Fix Gallery.postprocess()
whitphx Nov 21, 2023
719b436
Fix `download()` to fallback to `window.open()` when CORS is not allowed
whitphx Nov 21, 2023
a819a5e
Fix `gr.Gallery` to leave as None so it will be replaced with a loca…
whitphx Nov 21, 2023
b73aed3
Align a variable name to its type name
whitphx Nov 21, 2023
4e2aa3f
Fix Gallery's tests
whitphx Nov 21, 2023
007caa2
Fix the frontend test for gallery
whitphx Nov 21, 2023
cc0edf1
Revert "Fix `gr.Gallery` to leave as None so it will be replaced wit…
whitphx Nov 22, 2023
21251bd
Revert "Fix Gallery's tests"
whitphx Nov 22, 2023
7c7136f
Revert "Fix the frontend test for gallery"
whitphx Nov 22, 2023
b1d04e8
Fix for linter
whitphx Nov 22, 2023
93a173d
Add a test about the download button
whitphx Nov 22, 2023
a282421
Fix type defs on Gallery.postprocess
whitphx Nov 22, 2023
6e556c3
Improve TestGallery
whitphx Nov 22, 2023
3cd08ac
add changeset
gradio-pr-bot Nov 22, 2023
ee3f125
Merge branch 'main' into fix-gallery-download-button
whitphx Dec 5, 2023
5c802ee
Merge branch 'main' into fix-gallery-download-button
abidlabs Dec 7, 2023
3403e48
Merge branch 'main' into fix-gallery-download-button
abidlabs Dec 8, 2023
f2bfad0
Update gradio/components/gallery.py
whitphx Dec 9, 2023
4d6e127
Update gradio/components/gallery.py
whitphx Dec 9, 2023
2bc9b00
Revert "Update gradio/components/gallery.py"
whitphx Dec 9, 2023
0c1cd2d
Revert "Update gradio/components/gallery.py"
whitphx Dec 9, 2023
69ab93c
Use `tuple` instead of `typing.Tuple`
whitphx Dec 9, 2023
154817b
Revert "Use `tuple` instead of `typing.Tuple`"
whitphx Dec 9, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/good-areas-trade.md
@@ -0,0 +1,6 @@
---
"@gradio/gallery": patch
"gradio": patch
---

fix:Fix the download button of the `gr.Gallery()` component to work
23 changes: 17 additions & 6 deletions gradio/components/gallery.py
Expand Up @@ -3,7 +3,8 @@
from __future__ import annotations

from pathlib import Path
from typing import Any, Callable, List, Literal, Optional
from typing import Any, Callable, List, Literal, Optional, Tuple, Union
from urllib.parse import urlparse

import numpy as np
from gradio_client.documentation import document, set_documentation_group
Expand All @@ -18,6 +19,10 @@
set_documentation_group("component")


GalleryImageType = Union[np.ndarray, _Image.Image, Path, str]
whitphx marked this conversation as resolved.
Show resolved Hide resolved
CaptionedGalleryImageType = Tuple[GalleryImageType, str]
whitphx marked this conversation as resolved.
Show resolved Hide resolved


class GalleryImage(GradioModel):
image: FileData
caption: Optional[str] = None
Expand Down Expand Up @@ -125,9 +130,7 @@ def __init__(

def postprocess(
self,
value: list[np.ndarray | _Image.Image | str]
| list[tuple[np.ndarray | _Image.Image | str, str]]
| None,
value: list[GalleryImageType | CaptionedGalleryImageType] | None,
) -> GalleryData:
"""
Parameters:
Expand All @@ -141,6 +144,7 @@ def postprocess(
for img in value:
url = None
caption = None
orig_name = None
if isinstance(img, (tuple, list)):
img, caption = img
if isinstance(img, np.ndarray):
Expand All @@ -155,13 +159,20 @@ def postprocess(
file_path = str(utils.abspath(file))
elif isinstance(img, str):
file_path = img
url = img if is_http_url_like(img) else None
if is_http_url_like(img):
url = img
orig_name = Path(urlparse(img).path).name
whitphx marked this conversation as resolved.
Show resolved Hide resolved
else:
url = None
orig_name = Path(img).name
elif isinstance(img, Path):
file_path = str(img)
orig_name = img.name
else:
raise ValueError(f"Cannot process type as image: {type(img)}")
entry = GalleryImage(
image=FileData(path=file_path, url=url), caption=caption
image=FileData(path=file_path, url=url, orig_name=orig_name),
caption=caption,
)
output.append(entry)
return GalleryData(root=output)
Expand Down
9 changes: 8 additions & 1 deletion js/app/test/gallery_component_events.spec.ts
Expand Up @@ -15,10 +15,17 @@ test("Gallery preview mode displays all images correctly.", async ({
).toEqual("https://gradio-builds.s3.amazonaws.com/assets/cheetah-003.jpg");
});

test("Gallery select event returns the right value", async ({ page }) => {
test("Gallery select event returns the right value and the download button works correctly", async ({
page
}) => {
await page.getByRole("button", { name: "Run" }).click();
await page.getByLabel("Thumbnail 2 of 3").click();
await expect(page.getByLabel("Select Data")).toHaveValue(
"https://gradio-builds.s3.amazonaws.com/assets/lite-logo.png"
);

const downloadPromise = page.waitForEvent("download");
await page.getByLabel("Download").click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("lite-logo.png");
});
136 changes: 81 additions & 55 deletions js/gallery/shared/Gallery.svelte
Expand Up @@ -12,11 +12,14 @@
import { IconButton } from "@gradio/atoms";
import type { I18nFormatter } from "@gradio/utils";

type GalleryImage = { image: FileData; caption: string | null };
type GalleryData = GalleryImage[];

export let show_label = true;
export let label: string;
export let root = "";
export let proxy_url: null | string = null;
export let value: { image: FileData; caption: string | null }[] | null = null;
export let value: GalleryData | null = null;
export let columns: number | number[] | undefined = [2];
export let rows: number | number[] | undefined = undefined;
export let height: number | "auto" = "auto";
Expand All @@ -37,25 +40,24 @@
// tracks whether the value of the gallery was reset
let was_reset = true;

$: was_reset = value == null || value.length == 0 ? true : was_reset;
$: was_reset = value == null || value.length === 0 ? true : was_reset;

let _value: { image: FileData; caption: string | null }[] | null = null;
$: _value =
value === null
let resolved_value: GalleryData | null = null;
$: resolved_value =
value == null
? null
: value.map((data) => ({
image: normalise_file(data.image, root, proxy_url) as FileData,
caption: data.caption
}));

let prevValue: { image: FileData; caption: string | null }[] | null | null =
value;
if (selected_index === null && preview && value?.length) {
let prev_value: GalleryData | null = value;
if (selected_index == null && preview && value?.length) {
selected_index = 0;
}
let old_selected_index: number | null = selected_index;

$: if (!dequal(prevValue, value)) {
$: if (!dequal(prev_value, value)) {
// When value is falsy (clear button or first load),
// preview determines the selected image
if (was_reset) {
Expand All @@ -65,19 +67,18 @@
// gallery has at least as many elements as it did before
} else {
selected_index =
selected_index !== null &&
value !== null &&
selected_index < value.length
selected_index != null && value != null && selected_index < value.length
? selected_index
: null;
}
dispatch("change");
prevValue = value;
prev_value = value;
}

$: previous =
((selected_index ?? 0) + (_value?.length ?? 0) - 1) % (_value?.length ?? 0);
$: next = ((selected_index ?? 0) + 1) % (_value?.length ?? 0);
((selected_index ?? 0) + (resolved_value?.length ?? 0) - 1) %
(resolved_value?.length ?? 0);
$: next = ((selected_index ?? 0) + 1) % (resolved_value?.length ?? 0);

function handle_preview_click(event: MouseEvent): void {
const element = event.target as HTMLElement;
Expand Down Expand Up @@ -111,28 +112,13 @@
}
}

function isFileData(obj: any): obj is FileData {
return typeof obj === "object" && obj !== null && "data" in obj;
}

function getHrefValue(selected: any): string {
if (isFileData(selected)) {
return selected.path;
} else if (typeof selected === "string") {
return selected;
} else if (Array.isArray(selected)) {
return getHrefValue(selected[0]);
}
return "";
}

$: {
if (selected_index !== old_selected_index) {
old_selected_index = selected_index;
if (selected_index !== null) {
dispatch("select", {
index: selected_index,
value: _value?.[selected_index]
value: resolved_value?.[selected_index]
});
}
}
Expand Down Expand Up @@ -175,27 +161,69 @@

let client_height = 0;
let window_height = 0;

// Unlike `gr.Image()`, images specified via remote URLs are not cached in the server
// and their remote URLs are directly passed to the client as `value[].image.url`.
// 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),
// so we need to download the image via JS as below.
async function download(file_url: string, name: string): Promise<void> {
let response;
try {
response = await fetch(file_url);
} catch (error) {
if (error instanceof TypeError) {
// If CORS is not allowed (https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#checking_that_the_fetch_was_successful),
// open the link in a new tab instead, mimicing the behavior of the `download` attribute for remote URLs,
// which is not ideal, but a reasonable fallback.
window.open(file_url, "_blank", "noreferrer");
return;
}

throw error;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = name;
link.click();
URL.revokeObjectURL(url);
}

$: selected_image =
selected_index != null && resolved_value != null
? resolved_value[selected_index]
: null;
</script>

<svelte:window bind:innerHeight={window_height} />

{#if show_label}
<BlockLabel {show_label} Icon={Image} label={label || "Gallery"} />
{/if}
{#if value === null || _value === null || _value.length === 0}
{#if value == null || resolved_value == null || resolved_value.length === 0}
<Empty unpadded_box={true} size="large"><Image /></Empty>
{:else}
{#if selected_index !== null && allow_preview}
{#if selected_image && allow_preview}
<button on:keydown={on_keydown} class="preview">
<div class="icon-buttons">
{#if show_download_button}
<a
href={getHrefValue(value[selected_index])}
target={window.__is_colab__ ? "_blank" : null}
download="image"
>
<IconButton Icon={Download} label={i18n("common.download")} />
</a>
<div class="download-button-container">
<IconButton
Icon={Download}
label={i18n("common.download")}
on:click={() => {
const image = selected_image?.image;
if (image == null) {
return;
}
const { url, orig_name } = image;
if (url) {
download(url, orig_name ?? "image");
}
}}
/>
</div>
{/if}

<ModifyUpload
Expand All @@ -207,37 +235,35 @@
<button
class="image-button"
on:click={(event) => handle_preview_click(event)}
style="height: calc(100% - {_value[selected_index].caption
? '80px'
: '60px'})"
style="height: calc(100% - {selected_image.caption ? '80px' : '60px'})"
aria-label="detailed view of selected image"
>
<img
data-testid="detailed-image"
src={_value[selected_index].image.url}
alt={_value[selected_index].caption || ""}
title={_value[selected_index].caption || null}
class:with-caption={!!_value[selected_index].caption}
src={selected_image.image.url}
alt={selected_image.caption || ""}
title={selected_image.caption || null}
class:with-caption={!!selected_image.caption}
loading="lazy"
/>
</button>
{#if _value[selected_index]?.caption}
{#if selected_image?.caption}
<caption class="caption">
{_value[selected_index].caption}
{selected_image.caption}
</caption>
{/if}
<div
bind:this={container_element}
class="thumbnails scroll-hide"
data-testid="container_el"
>
{#each _value as image, i}
{#each resolved_value as image, i}
<button
bind:this={el[i]}
on:click={() => (selected_index = i)}
class="thumbnail-item thumbnail-small"
class:selected={selected_index === i}
aria-label={"Thumbnail " + (i + 1) + " of " + _value.length}
aria-label={"Thumbnail " + (i + 1) + " of " + resolved_value.length}
>
<img
src={image.image.url}
Expand Down Expand Up @@ -268,17 +294,17 @@
{i18n}
on:share
on:error
value={_value}
value={resolved_value}
formatter={format_gallery_for_sharing}
/>
</div>
{/if}
{#each _value as entry, i}
{#each resolved_value as entry, i}
<button
class="thumbnail-item thumbnail-lg"
class:selected={selected_index === i}
on:click={() => (selected_index = i)}
aria-label={"Thumbnail " + (i + 1) + " of " + _value.length}
aria-label={"Thumbnail " + (i + 1) + " of " + resolved_value.length}
>
<img
alt={entry.caption || ""}
Expand Down Expand Up @@ -465,7 +491,7 @@
right: 0;
}

.icon-buttons a {
.icon-buttons .download-button-container {
margin: var(--size-1) 0;
}
</style>