@gradio/imageeditor 0.20.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- <script lang="ts" context="module">
1
+ <script module lang="ts">
2
2
  import type { Tool, Subtool } from "./Toolbar.svelte";
3
3
 
4
4
  export const EDITOR_KEY = Symbol("editor");
@@ -58,6 +58,7 @@
58
58
  full_history = $bindable(null),
59
59
  has_drawn = $bindable(false),
60
60
  can_undo = $bindable(false),
61
+ has_user_edits = $bindable(false),
61
62
  onclear,
62
63
  onsave,
63
64
  onchange,
@@ -90,6 +91,7 @@
90
91
  full_history?: CommandNode | null;
91
92
  has_drawn?: boolean;
92
93
  can_undo?: boolean;
94
+ has_user_edits?: boolean;
93
95
  onclear?: () => void;
94
96
  onsave?: () => void;
95
97
  onchange?: () => void;
@@ -182,6 +184,7 @@
182
184
  }
183
185
 
184
186
  let pending_bg: Promise<void>;
187
+ let loading_value = 0;
185
188
  /**
186
189
  * Adds an image to the editor from a URL
187
190
  * @param {string | FileData} source - The URL of the image or a FileData object
@@ -199,6 +202,8 @@
199
202
  | any
200
203
  ): Promise<void> {
201
204
  if (!editor || !source || !check_if_should_init()) return;
205
+ has_user_edits = false;
206
+ loading_value += 1;
202
207
  let url: string;
203
208
 
204
209
  if (typeof source === "string") {
@@ -207,6 +212,7 @@
207
212
  url = source.url;
208
213
  } else {
209
214
  console.warn("Invalid source provided to add_image_from_url:", source);
215
+ loading_value -= 1;
210
216
  return;
211
217
  }
212
218
 
@@ -222,6 +228,8 @@
222
228
  oninput?.();
223
229
  } catch (error) {
224
230
  console.error("Error adding image from URL:", error);
231
+ } finally {
232
+ loading_value -= 1;
225
233
  }
226
234
  }
227
235
 
@@ -234,19 +242,23 @@
234
242
  source: FileData[] | any
235
243
  ): Promise<void> {
236
244
  if (!editor || !source.length || !check_if_should_init()) return;
245
+ has_user_edits = false;
246
+ loading_value += 1;
237
247
 
238
- if (
239
- Array.isArray(source) &&
240
- source.every((item) => item?.meta?._type === "gradio.FileData")
241
- ) {
242
- try {
248
+ try {
249
+ if (
250
+ Array.isArray(source) &&
251
+ source.every((item) => item?.meta?._type === "gradio.FileData")
252
+ ) {
243
253
  await pending_bg;
244
254
  await editor.add_layers_from_url(source.map((item) => item.url));
245
255
  onchange?.();
246
256
  oninput?.();
247
- } catch (error) {
248
- console.error("Error adding layer from URL:", error);
249
257
  }
258
+ } catch (error) {
259
+ console.error("Error adding layer from URL:", error);
260
+ } finally {
261
+ loading_value -= 1;
250
262
  }
251
263
  }
252
264
 
@@ -425,6 +437,7 @@
425
437
  });
426
438
 
427
439
  editor.on("change", () => {
440
+ if (loading_value === 0) has_user_edits = true;
428
441
  onchange?.();
429
442
  oninput?.();
430
443
  full_history = editor.command_manager.history;
@@ -782,6 +795,7 @@
782
795
  {#if ready}
783
796
  {#if current_subtool !== "crop"}
784
797
  <Controls
798
+ {i18n}
785
799
  {changeable}
786
800
  onset_zoom={(zoom) => handle_zoom_change(zoom)}
787
801
  onzoom_in={() => zoom_in_out("in")}
@@ -806,6 +820,7 @@
806
820
 
807
821
  {#if current_subtool !== "crop"}
808
822
  <Toolbar
823
+ {i18n}
809
824
  {sources}
810
825
  {transforms}
811
826
  background={background_image}
@@ -846,6 +861,7 @@
846
861
 
847
862
  {#if current_subtool !== "crop" && !layer_options.disabled}
848
863
  <SecondaryToolbar
864
+ {i18n}
849
865
  enable_additional_layers={layer_options.allow_additional_layers}
850
866
  layers={editor.layers}
851
867
  onnew_layer={() => {
@@ -884,7 +900,7 @@
884
900
  <div class="crop-confirm-button">
885
901
  <IconButton
886
902
  Icon={Check}
887
- label="Confirm crop"
903
+ label={i18n("image_editor.confirm_crop")}
888
904
  show_label={true}
889
905
  size="large"
890
906
  padded={true}
@@ -11,8 +11,10 @@
11
11
  } from "@gradio/icons";
12
12
  import { IconButton } from "@gradio/atoms";
13
13
  import type { Writable } from "svelte/store";
14
+ import type { I18nFormatter } from "@gradio/utils";
14
15
 
15
16
  let {
17
+ i18n,
16
18
  layers,
17
19
  enable_additional_layers = true,
18
20
  enable_layers = true,
@@ -23,6 +25,7 @@
23
25
  ondelete_layer,
24
26
  ontoggle_layer_visibility
25
27
  }: {
28
+ i18n: I18nFormatter;
26
29
  layers: Writable<{
27
30
  active_layer: string;
28
31
  layers: {
@@ -58,6 +61,13 @@
58
61
  function delete_layer(id: string): void {
59
62
  ondelete_layer?.(id);
60
63
  }
64
+
65
+ function localize_layer_name(name: string): string {
66
+ const generated_layer_name = /^Layer (\d+)$/.exec(name);
67
+ return generated_layer_name
68
+ ? `${i18n("image_editor.layer")} ${generated_layer_name[1]}`
69
+ : name;
70
+ }
61
71
  </script>
62
72
 
63
73
  {#if enable_layers}
@@ -68,15 +78,18 @@
68
78
  >
69
79
  <button
70
80
  class="layer-title-button"
71
- aria-label="Show Layers"
81
+ aria-label={i18n("image_editor.show_layers")}
72
82
  onclick={(event) => {
73
83
  event.stopPropagation();
74
84
  show_layers = !show_layers;
75
85
  }}
76
86
  >
77
87
  {show_layers
78
- ? "Layers"
79
- : $layers.layers.find((l) => l.id === $layers.active_layer)?.name}
88
+ ? i18n("image_editor.layers")
89
+ : localize_layer_name(
90
+ $layers.layers.find((l) => l.id === $layers.active_layer)?.name ??
91
+ ""
92
+ )}
80
93
  <span class="icon"><Layers /></span>
81
94
  </button>
82
95
  {#if show_layers}
@@ -108,7 +121,7 @@
108
121
  onclick={(event) => {
109
122
  event.stopPropagation();
110
123
  change_layer(id);
111
- }}>{name}</button
124
+ }}>{localize_layer_name(name)}</button
112
125
  >
113
126
  {#if $layers.layers.length > 1}
114
127
  <div>
@@ -150,7 +163,7 @@
150
163
  <li class="add-layer">
151
164
  <IconButton
152
165
  Icon={Plus}
153
- label="Add Layer"
166
+ label={i18n("image_editor.add_layer")}
154
167
  onclick={(e) => {
155
168
  e.stopPropagation();
156
169
  new_layer();
@@ -1,4 +1,4 @@
1
- <script lang="ts" context="module">
1
+ <script module lang="ts">
2
2
  export type Position =
3
3
  | "top-left"
4
4
  | "top"
@@ -1,8 +1,10 @@
1
1
  <script lang="ts">
2
2
  import Layers from "./Layers.svelte";
3
3
  import type { Writable } from "svelte/store";
4
+ import type { I18nFormatter } from "@gradio/utils";
4
5
 
5
6
  let {
7
+ i18n,
6
8
  layers,
7
9
  enable_additional_layers = true,
8
10
  enable_layers = true,
@@ -13,6 +15,7 @@
13
15
  ondelete_layer,
14
16
  ontoggle_layer_visibility
15
17
  }: {
18
+ i18n: I18nFormatter;
16
19
  layers: Writable<{
17
20
  active_layer: string;
18
21
  layers: {
@@ -37,6 +40,7 @@
37
40
  <div class="toolbar-wrap">
38
41
  {#if enable_layers}
39
42
  <Layers
43
+ {i18n}
40
44
  {layers}
41
45
  {enable_additional_layers}
42
46
  {enable_layers}
@@ -1,4 +1,4 @@
1
- <script lang="ts" context="module">
1
+ <script module lang="ts">
2
2
  export type Tool = "image" | "draw" | "erase" | "pan";
3
3
  export type Subtool =
4
4
  | "upload"
@@ -13,6 +13,7 @@
13
13
 
14
14
  <script lang="ts">
15
15
  import IconButton from "./IconButton.svelte";
16
+ import type { I18nFormatter } from "@gradio/utils";
16
17
  import {
17
18
  Image,
18
19
  Brush,
@@ -33,6 +34,7 @@
33
34
  } from "./brush/types";
34
35
 
35
36
  let {
37
+ i18n,
36
38
  tool = "image",
37
39
  subtool = null,
38
40
  background = false,
@@ -51,6 +53,7 @@
51
53
  ontool_change,
52
54
  onsubtool_change
53
55
  }: {
56
+ i18n: I18nFormatter;
54
57
  tool?: Tool;
55
58
  subtool?: Subtool;
56
59
  background?: boolean;
@@ -113,7 +116,7 @@
113
116
  {#if can_edit_image}
114
117
  <IconButton
115
118
  Icon={Image}
116
- label="Image"
119
+ label={i18n("image.image")}
117
120
  highlight={tool === "image"}
118
121
  onclick={(e) => handle_tool_click(e, "image")}
119
122
  size="medium"
@@ -124,7 +127,7 @@
124
127
  {#if brush_options}
125
128
  <IconButton
126
129
  Icon={Brush}
127
- label="Brush"
130
+ label={i18n("image_editor.brush")}
128
131
  onclick={(e) => handle_tool_click(e, "draw")}
129
132
  highlight={tool === "draw"}
130
133
  size="medium"
@@ -135,7 +138,7 @@
135
138
  {#if eraser_options}
136
139
  <IconButton
137
140
  Icon={Erase}
138
- label="Erase"
141
+ label={i18n("image_editor.erase")}
139
142
  onclick={(e) => handle_tool_click(e, "erase")}
140
143
  highlight={tool === "erase"}
141
144
  size="medium"
@@ -155,7 +158,7 @@
155
158
  {#if can_crop}
156
159
  <IconButton
157
160
  Icon={Crop}
158
- label="Crop"
161
+ label={i18n("image_editor.crop")}
159
162
  onclick={(e) => handle_subtool_click(e, "crop")}
160
163
  highlight={subtool === "crop"}
161
164
  size="medium"
@@ -167,7 +170,7 @@
167
170
  {#if can_resize}
168
171
  <IconButton
169
172
  Icon={Resize}
170
- label="Resize"
173
+ label={i18n("image_editor.resize")}
171
174
  onclick={(e) => handle_subtool_click(e, "size")}
172
175
  highlight={subtool === "size"}
173
176
  size="medium"
@@ -180,7 +183,7 @@
180
183
  {#if can_upload}
181
184
  <IconButton
182
185
  Icon={Upload}
183
- label="Upload"
186
+ label={i18n("image_editor.upload")}
184
187
  onclick={(e) => handle_subtool_click(e, "upload")}
185
188
  highlight={subtool === "upload"}
186
189
  size="medium"
@@ -192,7 +195,7 @@
192
195
  {#if can_paste}
193
196
  <IconButton
194
197
  Icon={ImagePaste}
195
- label="Paste"
198
+ label={i18n("image_editor.paste")}
196
199
  onclick={(e) => handle_subtool_click(e, "paste")}
197
200
  highlight={subtool === "paste"}
198
201
  size="large"
@@ -204,7 +207,7 @@
204
207
  {#if can_webcam}
205
208
  <IconButton
206
209
  Icon={Webcam}
207
- label="Webcam"
210
+ label={i18n("image_editor.webcam")}
208
211
  onclick={(e) => handle_subtool_click(e, "webcam")}
209
212
  highlight={subtool === "webcam"}
210
213
  size="medium"
@@ -219,7 +222,7 @@
219
222
  {#if tool === "draw" && brush_options}
220
223
  <IconButton
221
224
  Icon={ColorPickerSolid}
222
- label="Color"
225
+ label={i18n("image_editor.color")}
223
226
  color={selected_color}
224
227
  onclick={(e) => handle_subtool_click(e, "color")}
225
228
  size="medium"
@@ -229,7 +232,7 @@
229
232
  />
230
233
  <IconButton
231
234
  Icon={BrushSize}
232
- label="Brush Size"
235
+ label={i18n("image_editor.brush_size")}
233
236
  onclick={(e) => handle_subtool_click(e, "size")}
234
237
  highlight={subtool === "size"}
235
238
  size="medium"
@@ -264,7 +267,7 @@
264
267
  {#if tool === "erase" && eraser_options}
265
268
  <IconButton
266
269
  Icon={BrushSize}
267
- label="Eraser Size"
270
+ label={i18n("image_editor.eraser_size")}
268
271
  onclick={(e) => handle_subtool_click(e, "size")}
269
272
  highlight={subtool === "size"}
270
273
  size="medium"
@@ -0,0 +1,77 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
2
+ import { Application, Container } from "pixi.js";
3
+
4
+ import { LayerManager } from "../core/layers";
5
+ import type { ImageEditorContext } from "../core/editor";
6
+ import { BrushCommand } from "./brush-textures";
7
+
8
+ describe("BrushCommand", () => {
9
+ let app: Application;
10
+ let image_container: Container;
11
+ let layer_manager: LayerManager;
12
+ let context: ImageEditorContext;
13
+
14
+ beforeEach(async () => {
15
+ app = new Application();
16
+ await app.init({
17
+ width: 32,
18
+ height: 32,
19
+ backgroundAlpha: 0,
20
+ preference: "webgl"
21
+ });
22
+ image_container = new Container();
23
+ layer_manager = new LayerManager(image_container, app, true, false, 0, {
24
+ allow_additional_layers: true,
25
+ layers: ["Layer 1"],
26
+ disabled: false
27
+ });
28
+ layer_manager.create_layer({
29
+ width: 32,
30
+ height: 32,
31
+ user_created: true,
32
+ layer_id: "mask",
33
+ make_active: true
34
+ });
35
+ context = {
36
+ app,
37
+ image_container,
38
+ layer_manager
39
+ } as ImageEditorContext;
40
+ });
41
+
42
+ afterEach(() => {
43
+ image_container.destroy({ children: true });
44
+ app.destroy(true);
45
+ });
46
+
47
+ test("keeps repeated strokes of the same color at a fixed opacity", async () => {
48
+ const stroke = {
49
+ layer_id: "mask",
50
+ segments: [
51
+ {
52
+ from_x: 16,
53
+ from_y: 16,
54
+ to_x: 16,
55
+ to_y: 16,
56
+ size: 5,
57
+ color: "#ff0000",
58
+ opacity: 0.25,
59
+ mode: "draw" as const
60
+ }
61
+ ]
62
+ };
63
+
64
+ await new BrushCommand(context, stroke).execute(context);
65
+ const texture = layer_manager.get_layer_textures("mask")!.draw;
66
+ const first = app.renderer.extract.pixels(texture);
67
+
68
+ await new BrushCommand(context, stroke).execute(context);
69
+ const second = app.renderer.extract.pixels(texture);
70
+
71
+ const pixel_index = (16 * first.width + 16) * 4;
72
+ const first_alpha = first.pixels[pixel_index + 3];
73
+
74
+ expect(first_alpha).toBeGreaterThan(0);
75
+ expect(Array.from(second.pixels)).toEqual(Array.from(first.pixels));
76
+ });
77
+ });
@@ -88,14 +88,13 @@ export class BrushCommand implements Command {
88
88
 
89
89
  if (draw_segments.length > 0) {
90
90
  const graphics = new Graphics();
91
- const container = new Container();
92
- container.addChild(graphics);
93
- let alpha = 1;
91
+ const replacement_container = new Container();
92
+ const render_root = new Container();
93
+ replacement_container.blendMode = "none";
94
+ replacement_container.addChild(graphics);
95
+ render_root.addChild(replacement_container);
94
96
 
95
97
  for (const segment of draw_segments) {
96
- if (segment.opacity < alpha) {
97
- alpha = segment.opacity;
98
- }
99
98
  let colorValue = 0xffffff;
100
99
  if (segment.color.startsWith("#")) {
101
100
  colorValue = parseInt(segment.color.replace("#", "0x"), 16);
@@ -103,37 +102,19 @@ export class BrushCommand implements Command {
103
102
 
104
103
  graphics.setFillStyle({
105
104
  color: colorValue,
106
- alpha: 1
105
+ alpha: segment.opacity
107
106
  });
108
107
 
109
108
  this.render_segment_to_graphics(graphics, segment);
110
109
  }
111
110
 
112
- // we need a sprite in order to set the alpha and for that we need a texture
113
- // i'm not entirely sure why other approaches didn't work
114
- const alpha_sprite_texture = RenderTexture.create({
115
- width: target_texture.width,
116
- height: target_texture.height,
117
- resolution: window.devicePixelRatio || 1
118
- });
119
-
120
- const alpha_sprite = new Sprite(alpha_sprite_texture);
121
- this.context.app.renderer.render({
122
- container: container,
123
- target: alpha_sprite_texture
124
- });
125
-
126
- alpha_sprite.alpha = alpha;
127
-
128
111
  this.context.app.renderer.render({
129
- container: alpha_sprite,
112
+ container: render_root,
130
113
  target: target_texture,
131
114
  clear: false
132
115
  });
133
116
 
134
- container.destroy({ children: true });
135
- alpha_sprite.destroy();
136
- alpha_sprite_texture.destroy();
117
+ render_root.destroy({ children: true });
137
118
  }
138
119
 
139
120
  if (erase_segments.length > 0) {