From 33c7b2a1f985c08652164f357a0d55f28000a1ba Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:07:11 -0500 Subject: [PATCH 1/7] Fix: canvas text tool broke global hotkeys (#8887) * Initial plan * Fix canvas text tool breaking hotkeys when canvas manager is null Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(frontend): fix eslint issue --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein --- .../hooks/useIsUncommittedCanvasTextSessionActive.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/hooks/useIsUncommittedCanvasTextSessionActive.ts b/invokeai/frontend/web/src/features/controlLayers/hooks/useIsUncommittedCanvasTextSessionActive.ts index d3af4fd997e..a15020b3d39 100644 --- a/invokeai/frontend/web/src/features/controlLayers/hooks/useIsUncommittedCanvasTextSessionActive.ts +++ b/invokeai/frontend/web/src/features/controlLayers/hooks/useIsUncommittedCanvasTextSessionActive.ts @@ -6,5 +6,8 @@ import { useCallback } from 'react'; */ export const useIsUncommittedCanvasTextSessionActive = () => { const canvasManager = useCanvasManagerSafe(); - return useCallback(() => canvasManager?.tool.tools.text.$session.get() !== null, [canvasManager]); + return useCallback( + () => canvasManager !== null && canvasManager.tool.tools.text.$session.get() !== null, + [canvasManager] + ); }; From 1730193883ba6512d994808674ef2e1fd6b1431f Mon Sep 17 00:00:00 2001 From: John Hendrikx Date: Sat, 21 Feb 2026 16:13:18 +0100 Subject: [PATCH 2/7] Fix Create Board API call (#8866) Remove 5th parameter for function that expects 4 parameters Co-authored-by: Lincoln Stein --- invokeai/app/services/boards/boards_default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/app/services/boards/boards_default.py b/invokeai/app/services/boards/boards_default.py index 6efeaa1fea8..3ba0e7445f6 100644 --- a/invokeai/app/services/boards/boards_default.py +++ b/invokeai/app/services/boards/boards_default.py @@ -17,7 +17,7 @@ def create( board_name: str, ) -> BoardDTO: board_record = self.__invoker.services.board_records.save(board_name) - return board_record_to_dto(board_record, None, 0, 0, 0) + return board_record_to_dto(board_record, None, 0, 0) def get_dto(self, board_id: str) -> BoardDTO: board_record = self.__invoker.services.board_records.get(board_id) From c8dfea868145c9ea2aa13574f0a3d30b53e20897 Mon Sep 17 00:00:00 2001 From: DustyShoe <38873282+DustyShoe@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:18:15 +0200 Subject: [PATCH 3/7] Fix: Improve non square bbox coverage for linear gradient tool. (#8889) Co-authored-by: Lincoln Stein --- .../CanvasTool/CanvasGradientToolModule.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasGradientToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasGradientToolModule.ts index 745a5417355..3decb8636d5 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasGradientToolModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasGradientToolModule.ts @@ -134,19 +134,14 @@ export class CanvasGradientToolModule extends CanvasModuleBase { }; if (settings.gradientType === 'linear') { - const bboxCenter = { - x: bboxInLayer.x + bboxInLayer.width / 2, - y: bboxInLayer.y + bboxInLayer.height / 2, - }; - const cos = Math.cos(angle); - const sin = Math.sin(angle); - const halfWidth = (Math.abs(bboxInLayer.width * cos) + Math.abs(bboxInLayer.height * sin)) / 2; - const halfHeight = (Math.abs(bboxInLayer.width * sin) + Math.abs(bboxInLayer.height * cos)) / 2; + // Always render linear gradients on a rect that fully covers the bbox. + // Angle-dependent rect sizing can undershoot on non-square bboxes (e.g. 90deg on tall bboxes), + // leaving uncovered bands when the result is clipped back to bbox. rect = { - x: bboxCenter.x - halfWidth, - y: bboxCenter.y - halfHeight, - width: Math.max(halfWidth * 2, 1), - height: Math.max(halfHeight * 2, 1), + x: bboxInLayer.x, + y: bboxInLayer.y, + width: Math.max(bboxInLayer.width, 1), + height: Math.max(bboxInLayer.height, 1), }; } From 8772413ae8ec6525e8f3f5c5477e8cf6306729f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:17:16 +0000 Subject: [PATCH 4/7] Initial plan From 3f31c7d8b430fadfd4912d40fa0c2832f230de98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:22:13 +0000 Subject: [PATCH 5/7] Add invoke-readwatermark CLI command to decode invisible watermarks Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- invokeai/app/read_watermark.py | 34 +++++++++++++++++++ .../backend/image_util/invisible_watermark.py | 22 +++++++++++- pyproject.toml | 1 + 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 invokeai/app/read_watermark.py diff --git a/invokeai/app/read_watermark.py b/invokeai/app/read_watermark.py new file mode 100644 index 00000000000..9bc712d7780 --- /dev/null +++ b/invokeai/app/read_watermark.py @@ -0,0 +1,34 @@ +"""CLI command to decode invisible watermarks from Invoke-generated images.""" + +import argparse +import sys + +from PIL import Image + +from invokeai.backend.image_util.invisible_watermark import InvisibleWatermark + + +def read_watermark() -> None: + """Read and print invisible watermarks from a list of image files.""" + parser = argparse.ArgumentParser( + prog="invoke-readwatermark", + description="Decode invisible watermarks from Invoke-generated images.", + ) + parser.add_argument("images", nargs="+", metavar="IMAGE", help="Image file(s) to read watermarks from.") + parser.add_argument( + "--length", + type=int, + default=32, + metavar="BYTES", + help="Expected watermark length in bytes (default: %(default)s).", + ) + args = parser.parse_args() + + for path in args.images: + try: + image = Image.open(path) + except OSError as e: + print(f"{path}: error opening image: {e}", file=sys.stderr) + continue + watermark = InvisibleWatermark.decode_watermark(image, watermark_length=args.length) + print(f"{path}: {watermark}") diff --git a/invokeai/backend/image_util/invisible_watermark.py b/invokeai/backend/image_util/invisible_watermark.py index 5b0b2dbb5b1..8cef538349e 100644 --- a/invokeai/backend/image_util/invisible_watermark.py +++ b/invokeai/backend/image_util/invisible_watermark.py @@ -9,7 +9,7 @@ from PIL import Image import invokeai.backend.util.logging as logger -from invokeai.backend.image_util.imwatermark.vendor import WatermarkEncoder +from invokeai.backend.image_util.imwatermark.vendor import WatermarkDecoder, WatermarkEncoder class InvisibleWatermark: @@ -25,3 +25,23 @@ def add_watermark(cls, image: Image.Image, watermark_text: str) -> Image.Image: encoder.set_watermark("bytes", watermark_text.encode("utf-8")) bgr_encoded = encoder.encode(bgr, "dwtDct") return Image.fromarray(cv2.cvtColor(bgr_encoded, cv2.COLOR_BGR2RGB)).convert("RGBA") + + @classmethod + def decode_watermark(cls, image: Image.Image, watermark_length: int = 32) -> str: + """Decode an invisible watermark from an image. + + Args: + image: The PIL image to decode the watermark from. + watermark_length: The length of the watermark in bytes (default 32). + + Returns: + The decoded watermark text, or an empty string if decoding fails. + """ + bgr = cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2BGR) + decoder = WatermarkDecoder("bytes", watermark_length * 8) + try: + raw = decoder.decode(bgr, "dwtDct") + return raw.rstrip(b"\x00").decode("utf-8", errors="replace") + except (RuntimeError, ValueError, NameError) as e: + logger.debug("Failed to decode watermark: %s", e) + return "" diff --git a/pyproject.toml b/pyproject.toml index adfe5982baf..e6a2752075f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,6 +159,7 @@ explicit = true [project.scripts] "invokeai-web" = "invokeai.app.run_app:run_app" +"invoke-readwatermark" = "invokeai.app.read_watermark:read_watermark" [project.urls] "Homepage" = "https://invoke-ai.github.io/InvokeAI/" From 6a6699f26ff5b8d64fa1660c94122c9c9d10dc09 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 21 Feb 2026 11:27:11 -0500 Subject: [PATCH 6/7] fix(app): change location of the read_watermark module --- invokeai/app/{ => util}/read_watermark.py | 0 pyproject.toml | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename invokeai/app/{ => util}/read_watermark.py (100%) diff --git a/invokeai/app/read_watermark.py b/invokeai/app/util/read_watermark.py similarity index 100% rename from invokeai/app/read_watermark.py rename to invokeai/app/util/read_watermark.py diff --git a/pyproject.toml b/pyproject.toml index e6a2752075f..a7cf0d3a420 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,7 +159,7 @@ explicit = true [project.scripts] "invokeai-web" = "invokeai.app.run_app:run_app" -"invoke-readwatermark" = "invokeai.app.read_watermark:read_watermark" +"invoke-readwatermark" = "invokeai.app.util.read_watermark:read_watermark" [project.urls] "Homepage" = "https://invoke-ai.github.io/InvokeAI/" From 1c9ce54ff94e65ee08eafc8a96d40654dd86348a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:47:03 +0000 Subject: [PATCH 7/7] Fix default watermark_length from 32 to 8 bytes to match default InvokeAI watermark text Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- invokeai/app/util/read_watermark.py | 4 ++-- invokeai/backend/image_util/invisible_watermark.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/invokeai/app/util/read_watermark.py b/invokeai/app/util/read_watermark.py index 9bc712d7780..5fe492d2b04 100644 --- a/invokeai/app/util/read_watermark.py +++ b/invokeai/app/util/read_watermark.py @@ -18,9 +18,9 @@ def read_watermark() -> None: parser.add_argument( "--length", type=int, - default=32, + default=8, metavar="BYTES", - help="Expected watermark length in bytes (default: %(default)s).", + help="Expected watermark length in bytes (default: %(default)s, matching the default 'InvokeAI' watermark text).", ) args = parser.parse_args() diff --git a/invokeai/backend/image_util/invisible_watermark.py b/invokeai/backend/image_util/invisible_watermark.py index 8cef538349e..6a87f4da47e 100644 --- a/invokeai/backend/image_util/invisible_watermark.py +++ b/invokeai/backend/image_util/invisible_watermark.py @@ -27,12 +27,12 @@ def add_watermark(cls, image: Image.Image, watermark_text: str) -> Image.Image: return Image.fromarray(cv2.cvtColor(bgr_encoded, cv2.COLOR_BGR2RGB)).convert("RGBA") @classmethod - def decode_watermark(cls, image: Image.Image, watermark_length: int = 32) -> str: + def decode_watermark(cls, image: Image.Image, watermark_length: int = 8) -> str: """Decode an invisible watermark from an image. Args: image: The PIL image to decode the watermark from. - watermark_length: The length of the watermark in bytes (default 32). + watermark_length: The length of the watermark in bytes (default 8, matching the default "InvokeAI" watermark text). Returns: The decoded watermark text, or an empty string if decoding fails.