@gradio/imageeditor 0.15.5 → 0.16.0

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.
@@ -8,29 +8,53 @@ import {
8
8
  } from "pixi.js";
9
9
  import { type ImageEditorContext } from "../core/editor";
10
10
  import { type Command } from "../core/commands";
11
+
12
+ /**
13
+ * Represents a single drawing segment with all its parameters
14
+ */
15
+ interface BrushSegment {
16
+ from_x: number;
17
+ from_y: number;
18
+ to_x: number;
19
+ to_y: number;
20
+ size: number;
21
+ color: string;
22
+ opacity: number;
23
+ mode: "draw" | "erase";
24
+ }
25
+
26
+ /**
27
+ * Represents a complete brush stroke containing multiple segments
28
+ */
29
+ interface BrushStroke {
30
+ segments: BrushSegment[];
31
+ layer_id: string;
32
+ }
33
+
11
34
  export class BrushCommand implements Command {
12
- private layer_id: string;
13
- private original_texture: Texture;
14
- private final_texture: Texture;
35
+ private stroke_data: BrushStroke;
36
+ private context: ImageEditorContext;
37
+ private original_texture: Texture | null = null;
38
+
15
39
  name: string;
16
40
 
17
41
  constructor(
18
- private context: ImageEditorContext,
19
- layer_id: string,
20
- original_texture: Texture,
21
- final_texture: Texture
42
+ context: ImageEditorContext,
43
+ stroke_data: BrushStroke,
44
+ original_texture?: Texture
22
45
  ) {
23
46
  this.name = "Draw";
24
- this.layer_id = layer_id;
25
-
26
- this.original_texture = this.createTextureFrom(original_texture);
27
- this.final_texture = this.createTextureFrom(final_texture);
47
+ this.stroke_data = stroke_data;
48
+ this.context = context;
49
+ if (original_texture) {
50
+ this.original_texture = this.create_texture_from(original_texture);
51
+ }
28
52
  }
29
53
 
30
54
  /**
31
55
  * Creates a new texture with the same content as the source texture
32
56
  */
33
- private createTextureFrom(source: Texture): Texture {
57
+ private create_texture_from(source: Texture): Texture {
34
58
  const texture = RenderTexture.create({
35
59
  width: source.width,
36
60
  height: source.height,
@@ -49,26 +73,226 @@ export class BrushCommand implements Command {
49
73
  return texture;
50
74
  }
51
75
 
52
- async execute(): Promise<void> {
53
- const layer_textures = this.context.layer_manager.get_layer_textures(
54
- this.layer_id
76
+ /**
77
+ * Recreates the stroke by rendering all segments from the stored parameters
78
+ * Preserves the exact order of draw/erase operations
79
+ */
80
+ private render_stroke_from_data(
81
+ stroke_data: BrushStroke,
82
+ target_texture: RenderTexture
83
+ ): void {
84
+ const draw_segments = stroke_data.segments.filter((s) => s.mode === "draw");
85
+ const erase_segments = stroke_data.segments.filter(
86
+ (s) => s.mode === "erase"
87
+ );
88
+
89
+ if (draw_segments.length > 0) {
90
+ const graphics = new Graphics();
91
+ const container = new Container();
92
+ container.addChild(graphics);
93
+ let alpha = 1;
94
+
95
+ for (const segment of draw_segments) {
96
+ if (segment.opacity < alpha) {
97
+ alpha = segment.opacity;
98
+ }
99
+ let colorValue = 0xffffff;
100
+ if (segment.color.startsWith("#")) {
101
+ colorValue = parseInt(segment.color.replace("#", "0x"), 16);
102
+ }
103
+
104
+ graphics.setFillStyle({
105
+ color: colorValue,
106
+ alpha: 1
107
+ });
108
+
109
+ this.render_segment_to_graphics(graphics, segment);
110
+ }
111
+
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
+ this.context.app.renderer.render({
129
+ container: alpha_sprite,
130
+ target: target_texture,
131
+ clear: false
132
+ });
133
+
134
+ container.destroy({ children: true });
135
+ alpha_sprite.destroy();
136
+ alpha_sprite_texture.destroy();
137
+ }
138
+
139
+ if (erase_segments.length > 0) {
140
+ // create a temp texture to work with
141
+ const temp_content_texture = RenderTexture.create({
142
+ width: target_texture.width,
143
+ height: target_texture.height,
144
+ resolution: window.devicePixelRatio || 1
145
+ });
146
+
147
+ const copy_sprite = new Sprite(target_texture);
148
+ const copy_container = new Container();
149
+ copy_container.addChild(copy_sprite);
150
+
151
+ this.context.app.renderer.render({
152
+ container: copy_container,
153
+ target: temp_content_texture,
154
+ clear: true
155
+ });
156
+
157
+ // create a graphics object to draw the erase segments
158
+ const erase_graphics = new Graphics();
159
+ const erase_container = new Container();
160
+ erase_container.addChild(erase_graphics);
161
+
162
+ erase_graphics.setFillStyle({
163
+ color: 0xffffff,
164
+ alpha: 1.0
165
+ });
166
+
167
+ for (const segment of erase_segments) {
168
+ this.render_segment_to_graphics(erase_graphics, segment);
169
+ }
170
+
171
+ // create a separate texture to hold the mask
172
+ const mask_texture = RenderTexture.create({
173
+ width: target_texture.width,
174
+ height: target_texture.height,
175
+ resolution: window.devicePixelRatio || 1
176
+ });
177
+
178
+ this.context.app.renderer.render({
179
+ container: erase_container,
180
+ target: mask_texture,
181
+ clear: true
182
+ });
183
+
184
+ const content_sprite = new Sprite(temp_content_texture);
185
+ const mask_sprite = new Sprite(mask_texture);
186
+
187
+ // only now do we create a sprite from the original texture and add the mask
188
+ const masked_container = new Container();
189
+ masked_container.addChild(content_sprite);
190
+ masked_container.setMask({ mask: mask_sprite, inverse: true }); // inverse mask = erase
191
+
192
+ // now we can render the masked content back to the target texture
193
+ this.context.app.renderer.render({
194
+ container: masked_container,
195
+ target: target_texture,
196
+ clear: true
197
+ });
198
+
199
+ copy_container.destroy({ children: true });
200
+ erase_container.destroy({ children: true });
201
+ masked_container.destroy({ children: true });
202
+ temp_content_texture.destroy();
203
+ mask_texture.destroy();
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Renders a segment to a graphics object (extracted from renderSegment)
209
+ */
210
+ private render_segment_to_graphics(
211
+ graphics: Graphics,
212
+ segment: BrushSegment
213
+ ): void {
214
+ const distance = Math.sqrt(
215
+ Math.pow(segment.to_x - segment.from_x, 2) +
216
+ Math.pow(segment.to_y - segment.from_y, 2)
55
217
  );
218
+
219
+ if (distance < 0.1) {
220
+ graphics.circle(segment.from_x, segment.from_y, segment.size).fill();
221
+ } else {
222
+ const spacing = Math.max(segment.size / 3, 2);
223
+ const steps = Math.max(Math.ceil(distance / spacing), 2);
224
+
225
+ for (let i = 0; i < steps; i++) {
226
+ const t = i / (steps - 1);
227
+ const x = segment.from_x + (segment.to_x - segment.from_x) * t;
228
+ const y = segment.from_y + (segment.to_y - segment.from_y) * t;
229
+
230
+ graphics.circle(x, y, segment.size).fill();
231
+ }
232
+ }
233
+ }
234
+
235
+ async execute(context: ImageEditorContext): Promise<void> {
236
+ if (context) {
237
+ this.context = context;
238
+ }
239
+
240
+ let layer_textures = this.context.layer_manager.get_layer_textures(
241
+ this.stroke_data.layer_id
242
+ );
243
+
244
+ if (!layer_textures) {
245
+ const all_layers = this.context.layer_manager.get_layers();
246
+ const top_layer = all_layers[all_layers.length - 1];
247
+ layer_textures = this.context.layer_manager.get_layer_textures(
248
+ top_layer.id
249
+ );
250
+ }
251
+
56
252
  if (!layer_textures) return;
57
253
 
58
- const temp_sprite = new Sprite(this.final_texture);
254
+ // create a temporary texture to render the stroke
255
+ const temp_texture = RenderTexture.create({
256
+ width: layer_textures.draw.width,
257
+ height: layer_textures.draw.height,
258
+ resolution: window.devicePixelRatio || 1
259
+ });
260
+
261
+ // copy current layer content to temp texture
262
+ const current_sprite = new Sprite(layer_textures.draw);
59
263
  const temp_container = new Container();
60
- temp_container.addChild(temp_sprite);
264
+ temp_container.addChild(current_sprite);
61
265
 
62
- this.context.app.renderer.render(temp_container, {
63
- renderTexture: layer_textures.draw
266
+ this.context.app.renderer.render({
267
+ container: temp_container,
268
+ target: temp_texture,
269
+ clear: true
64
270
  });
65
271
 
66
272
  temp_container.destroy({ children: true });
273
+
274
+ this.render_stroke_from_data(this.stroke_data, temp_texture);
275
+
276
+ // copy final result back to layer texture
277
+ const final_sprite = new Sprite(temp_texture);
278
+ const final_container = new Container();
279
+ final_container.addChild(final_sprite);
280
+
281
+ this.context.app.renderer.render({
282
+ container: final_container,
283
+ target: layer_textures.draw,
284
+ clear: true
285
+ });
286
+
287
+ final_container.destroy({ children: true });
288
+ temp_texture.destroy();
67
289
  }
68
290
 
69
291
  async undo(): Promise<void> {
292
+ if (!this.original_texture) return;
293
+
70
294
  const layer_textures = this.context.layer_manager.get_layer_textures(
71
- this.layer_id
295
+ this.stroke_data.layer_id
72
296
  );
73
297
  if (!layer_textures) return;
74
298
 
@@ -76,8 +300,10 @@ export class BrushCommand implements Command {
76
300
  const temp_container = new Container();
77
301
  temp_container.addChild(temp_sprite);
78
302
 
79
- this.context.app.renderer.render(temp_container, {
80
- renderTexture: layer_textures.draw
303
+ this.context.app.renderer.render({
304
+ container: temp_container,
305
+ target: layer_textures.draw,
306
+ clear: true
81
307
  });
82
308
 
83
309
  temp_container.destroy({ children: true });
@@ -97,20 +323,19 @@ export class BrushTextures {
97
323
  private preview_sprite: Sprite | null = null;
98
324
  private erase_graphics: Graphics | null = null;
99
325
  private dimensions: { width: number; height: number };
100
-
326
+ private image_editor_context: ImageEditorContext;
327
+ private app: Application;
101
328
  private is_new_stroke = true;
102
329
 
103
330
  private current_opacity = 1.0;
104
-
105
- private current_mode: "draw" | "erase" = "draw";
106
-
107
331
  private original_layer_texture: Texture | null = null;
108
332
  private active_layer_id: string | null = null;
333
+ private current_stroke_segments: BrushSegment[] = [];
334
+
335
+ constructor(image_editor_context: ImageEditorContext, app: Application) {
336
+ this.image_editor_context = image_editor_context;
337
+ this.app = app;
109
338
 
110
- constructor(
111
- private image_editor_context: ImageEditorContext,
112
- private app: Application
113
- ) {
114
339
  this.dimensions = {
115
340
  width: this.image_editor_context.image_container.width,
116
341
  height: this.image_editor_context.image_container.height
@@ -145,15 +370,7 @@ export class BrushTextures {
145
370
 
146
371
  this.display_container = new Container();
147
372
 
148
- const active_layer =
149
- this.image_editor_context.layer_manager.get_active_layer();
150
- if (active_layer) {
151
- active_layer.addChild(this.display_container);
152
- } else {
153
- this.image_editor_context.image_container.addChild(
154
- this.display_container
155
- );
156
- }
373
+ this.image_editor_context.image_container.addChild(this.display_container);
157
374
 
158
375
  this.stroke_container = new Container();
159
376
  this.stroke_graphics = new Graphics();
@@ -274,13 +491,10 @@ export class BrushTextures {
274
491
  if (!this.erase_graphics || !this.erase_texture) return;
275
492
 
276
493
  this.erase_graphics.clear();
277
- this.erase_graphics.beginFill(0xffffff, 1);
278
- this.erase_graphics.drawRect(
279
- 0,
280
- 0,
281
- this.dimensions.width,
282
- this.dimensions.height
283
- );
494
+ this.erase_graphics.setFillStyle({ color: 0xffffff, alpha: 1.0 });
495
+ this.erase_graphics
496
+ .rect(0, 0, this.dimensions.width, this.dimensions.height)
497
+ .fill();
284
498
  this.erase_graphics.endFill();
285
499
 
286
500
  this.app.renderer.render(this.erase_graphics, {
@@ -318,63 +532,15 @@ export class BrushTextures {
318
532
  this.image_editor_context.layer_manager.get_layer_textures(layer.id);
319
533
  if (!layer_textures) return;
320
534
 
321
- const currentOpacity = this.current_opacity;
322
-
323
- if (this.current_mode === "draw") {
324
- const temp_container = new Container();
325
-
326
- const base_sprite = new Sprite(layer_textures.draw);
327
- temp_container.addChild(base_sprite);
328
-
329
- const stroke_sprite = new Sprite(this.stroke_texture);
330
- stroke_sprite.alpha = currentOpacity;
331
- temp_container.addChild(stroke_sprite);
332
-
333
- this.app.renderer.render(temp_container, {
334
- renderTexture: layer_textures.draw
335
- });
336
-
337
- temp_container.destroy({ children: true });
338
- } else {
339
- if (!this.stroke_texture) return;
340
-
341
- const erase_container = new Container();
342
-
343
- const content_sprite = new Sprite(layer_textures.draw);
344
- erase_container.addChild(content_sprite);
345
-
346
- const mask_sprite = new Sprite(this.stroke_texture);
347
-
348
- erase_container.setMask({ mask: mask_sprite, inverse: true });
349
-
350
- this.app.renderer.render(erase_container, {
351
- renderTexture: layer_textures.draw
352
- });
353
-
354
- erase_container.destroy({ children: true });
355
- }
356
-
357
- const final_texture = RenderTexture.create({
358
- width: this.dimensions.width,
359
- height: this.dimensions.height,
360
- resolution: window.devicePixelRatio || 1
361
- });
362
-
363
- const final_container = new Container();
364
- const final_sprite = new Sprite(layer_textures.draw);
365
- final_container.addChild(final_sprite);
366
-
367
- this.app.renderer.render(final_container, {
368
- renderTexture: final_texture
369
- });
370
-
371
- final_container.destroy({ children: true });
535
+ const stroke_data: BrushStroke = {
536
+ segments: [...this.current_stroke_segments],
537
+ layer_id: this.active_layer_id
538
+ };
372
539
 
373
540
  const brush_command = new BrushCommand(
374
541
  this.image_editor_context,
375
- this.active_layer_id,
376
- this.original_layer_texture,
377
- final_texture
542
+ stroke_data,
543
+ this.original_layer_texture
378
544
  );
379
545
 
380
546
  if (this.stroke_graphics) {
@@ -387,19 +553,20 @@ export class BrushTextures {
387
553
  clear_container.destroy();
388
554
 
389
555
  this.is_new_stroke = true;
390
-
391
- final_texture.destroy();
392
-
393
556
  this.original_layer_texture = null;
394
557
  this.active_layer_id = null;
558
+ this.current_stroke_segments = [];
395
559
 
396
- this.image_editor_context.command_manager.execute(brush_command);
560
+ this.image_editor_context.command_manager.execute(
561
+ brush_command,
562
+ this.image_editor_context
563
+ );
397
564
  }
398
565
 
399
566
  /**
400
567
  * Calculates the distance between two points.
401
568
  */
402
- private calculateDistance(
569
+ private calculate_distance(
403
570
  x1: number,
404
571
  y1: number,
405
572
  x2: number,
@@ -433,7 +600,6 @@ export class BrushTextures {
433
600
  this.preserve_canvas_state();
434
601
  }
435
602
 
436
- this.current_mode = mode;
437
603
  this.current_opacity =
438
604
  mode === "draw" ? Math.min(Math.max(opacity, 0), 1) : 0.5;
439
605
 
@@ -441,6 +607,7 @@ export class BrushTextures {
441
607
 
442
608
  if (this.is_new_stroke) {
443
609
  this.stroke_graphics.clear();
610
+ this.current_stroke_segments = [];
444
611
 
445
612
  const clear_container = new Container();
446
613
  this.app.renderer.render(clear_container, {
@@ -451,6 +618,17 @@ export class BrushTextures {
451
618
  this.is_new_stroke = false;
452
619
  }
453
620
 
621
+ this.current_stroke_segments.push({
622
+ from_x,
623
+ from_y,
624
+ to_x,
625
+ to_y,
626
+ size: scaled_size,
627
+ color,
628
+ opacity: this.current_opacity,
629
+ mode
630
+ });
631
+
454
632
  if (mode === "draw") {
455
633
  let colorValue = 0xffffff;
456
634
  try {
@@ -471,7 +649,7 @@ export class BrushTextures {
471
649
  });
472
650
  }
473
651
 
474
- const distance = this.calculateDistance(from_x, from_y, to_x, to_y);
652
+ const distance = this.calculate_distance(from_x, from_y, to_x, to_y);
475
653
 
476
654
  if (distance < 0.1) {
477
655
  this.stroke_graphics.circle(from_x, from_y, scaled_size).fill();
@@ -559,6 +737,17 @@ export class BrushTextures {
559
737
  return this.dimensions;
560
738
  }
561
739
 
740
+ /**
741
+ * Checks if textures are properly initialized.
742
+ */
743
+ get textures_initialized(): boolean {
744
+ return !!(
745
+ this.stroke_texture &&
746
+ this.display_container &&
747
+ this.preview_sprite
748
+ );
749
+ }
750
+
562
751
  /**
563
752
  * Cleanup all resources.
564
753
  */
@@ -158,13 +158,21 @@ export class BrushTool implements Tool {
158
158
  }
159
159
 
160
160
  if (this.brush_cursor) {
161
- this.brush_cursor.set_active(tool === "draw" || tool === "erase");
161
+ const should_be_active = tool === "draw" || tool === "erase";
162
+
163
+ this.brush_cursor.set_active(should_be_active);
162
164
  }
163
165
 
164
166
  const new_mode = tool === "erase" ? "erase" : "draw";
165
- if (tool === "erase" || tool === "draw") {
167
+ const mode_changed = this.state.mode !== new_mode;
168
+ const needs_brush_tool = tool === "erase" || tool === "draw";
169
+ const textures_initialized =
170
+ this.brush_textures?.textures_initialized ?? false;
171
+
172
+ if (needs_brush_tool && (mode_changed || !textures_initialized)) {
166
173
  this.brush_textures?.initialize_textures();
167
174
  }
175
+
168
176
  if (this.state.mode !== new_mode) {
169
177
  this.state.mode = new_mode;
170
178
 
@@ -198,6 +206,7 @@ export class BrushTool implements Tool {
198
206
  private on_pointer_down(event: FederatedPointerEvent): void {
199
207
  const current_layer =
200
208
  this.image_editor_context.layer_manager.get_active_layer();
209
+
201
210
  if (
202
211
  !current_layer?.visible ||
203
212
  (this.current_tool !== "erase" && this.current_tool !== "draw")
@@ -205,7 +214,9 @@ export class BrushTool implements Tool {
205
214
  return;
206
215
  }
207
216
 
208
- if (this.brush_cursor && !this.brush_cursor.is_over_image()) return;
217
+ if (this.brush_cursor && !this.brush_cursor.is_over_image()) {
218
+ return;
219
+ }
209
220
 
210
221
  if (this.brush_textures) {
211
222
  this.brush_textures.preserve_canvas_state();
@@ -1,4 +1,5 @@
1
- import { writable, type Writable } from "svelte/store";
1
+ import { writable } from "svelte/store";
2
+ import type { ImageEditorContext } from "./editor";
2
3
 
3
4
  /**
4
5
  * Base command interface that is added to the command_managers history
@@ -27,7 +28,7 @@ export interface Command {
27
28
  * Called by the command manager to execute the command, can act as a no-op if the work has already been done
28
29
  * This function must be able to recreate the command if the command is undone and redone (`stop`/`start`/`continue` will not be called again)
29
30
  */
30
- execute(): any | Promise<any>;
31
+ execute(context?: ImageEditorContext): any | Promise<any>;
31
32
  /**
32
33
  * Called by the command manager to undo the command
33
34
  * This function must be able to undo the work done by the execute function
@@ -52,6 +53,8 @@ export class CommandNode {
52
53
  }
53
54
 
54
55
  push(command: Command): void {
56
+ this.next = null;
57
+
55
58
  const node = new CommandNode(command);
56
59
  node.previous = this;
57
60
  this.next = node;
@@ -64,7 +67,6 @@ export class CommandNode {
64
67
  */
65
68
  export class CommandManager {
66
69
  history: CommandNode = new CommandNode();
67
-
68
70
  current_history = writable(this.history);
69
71
 
70
72
  undo(): void {
@@ -72,33 +74,48 @@ export class CommandManager {
72
74
  this.history.command?.undo();
73
75
  this.history = this.history.previous;
74
76
 
75
- this.current_history.set(this.history);
77
+ this.current_history.update(() => this.history);
76
78
  }
77
79
  }
78
- redo(): void {
80
+ redo(context?: ImageEditorContext): void {
79
81
  if (this.history.next) {
80
82
  this.history = this.history.next;
81
- this.history.command?.execute();
83
+ this.history.command?.execute(context);
84
+ this.current_history.update(() => this.history);
82
85
  }
83
-
84
- this.current_history.set(this.history);
85
86
  }
86
87
 
87
- execute(command: Command): void {
88
- command.execute();
88
+ async execute(command: Command, context: ImageEditorContext): Promise<void> {
89
+ await command.execute(context);
89
90
  this.history.push(command);
90
91
  this.history = this.history.next!;
91
92
 
92
- this.current_history.set(this.history);
93
+ this.current_history.update(() => this.history);
93
94
  }
94
95
 
95
- replay(full_history: CommandNode): void {
96
- setTimeout(() => {
97
- while (full_history.next) {
98
- this.execute(full_history.next.command!);
99
- full_history = full_history.next;
100
- }
101
- }, 1000);
96
+ async wait_for_next_frame(): Promise<void> {
97
+ return new Promise((resolve) => {
98
+ requestAnimationFrame(() => {
99
+ resolve();
100
+ });
101
+ });
102
+ }
103
+
104
+ async replay(
105
+ full_history: CommandNode,
106
+ context: ImageEditorContext
107
+ ): Promise<void> {
108
+ while (full_history.previous) {
109
+ full_history = full_history.previous;
110
+ }
111
+
112
+ while (full_history.next) {
113
+ await full_history.next.command!.execute(context);
114
+ full_history = full_history.next;
115
+ }
116
+
117
+ this.history = full_history;
118
+ this.current_history.update(() => this.history);
102
119
  }
103
120
 
104
121
  contains(command_name: string): boolean {
@@ -112,7 +129,6 @@ export class CommandManager {
112
129
 
113
130
  reset(): void {
114
131
  this.history = new CommandNode();
115
-
116
- this.current_history.set(this.history);
132
+ this.current_history.update(() => this.history);
117
133
  }
118
134
  }