diff --git a/py/image_manager/features/images/router.py b/py/image_manager/features/images/router.py index 4aa4548..5a39eb0 100644 --- a/py/image_manager/features/images/router.py +++ b/py/image_manager/features/images/router.py @@ -1627,11 +1627,106 @@ def _get_image_path(img_type: str, subfolder: str, filename: str) -> str | None: return full_path +def _strip_png_metadata_fast(data: bytes) -> bytes | None: + import io + import struct + + if len(data) < 8 or data[:8] != b"\x89PNG\r\n\x1a\n": + return None + out = io.BytesIO() + out.write(data[:8]) + offset = 8 + saw_any_chunk = False + saw_iend = False + while offset + 8 <= len(data): + chunk_length = struct.unpack(">I", data[offset : offset + 4])[0] + chunk_type = data[offset + 4 : offset + 8] + chunk_full_size = 12 + chunk_length + if offset + chunk_full_size > len(data): + return None + saw_any_chunk = True + if chunk_type == b"IEND": + saw_iend = True + if chunk_type not in (b"tEXt", b"zTXt", b"iTXt"): + out.write(data[offset : offset + chunk_full_size]) + offset += chunk_full_size + if not saw_any_chunk or not saw_iend: + return None + return out.getvalue() + + +def _strip_webp_metadata_fast(data: bytes) -> bytes | None: + import io + import struct + + if len(data) < 12 or data[:4] != b"RIFF" or data[8:12] != b"WEBP": + return None + out = io.BytesIO() + out.write(data[:12]) + offset = 12 + vp8x_pos = -1 + vp8x_data = bytearray() + saw_any_chunk = False + while offset + 8 <= len(data): + chunk_id = data[offset : offset + 4] + chunk_size = struct.unpack(" len(data): + return None + saw_any_chunk = True + chunk_data = data[offset : offset + chunk_full_size] + if chunk_id in (b"EXIF", b"XMP "): + pass # skip metadata + elif chunk_id == b"VP8X": + vp8x_pos = out.tell() + vp8x_data = bytearray(chunk_data) + out.write(vp8x_data) + else: + out.write(chunk_data) + offset += chunk_full_size + total_size = out.tell() + if not saw_any_chunk or total_size <= 12: + return None + if vp8x_pos != -1 and len(vp8x_data) >= 12: + flags = struct.unpack(" bytes: import io try: - with Image.open(image_path) as img: + with open(image_path, "rb") as f: + data = f.read() + + if data.startswith(b"\x89PNG\r\n\x1a\n"): + try: + res = _strip_png_metadata_fast(data) + if res is not None: + return res + except Exception as e: + logging.warning(f"[Meld] Fast PNG strip failed for {image_path}: {e}") + + if data.startswith(b"RIFF") and data[8:12] == b"WEBP": + try: + res = _strip_webp_metadata_fast(data) + if res is not None: + return res + except Exception as e: + logging.warning(f"[Meld] Fast WebP strip failed for {image_path}: {e}") + + # Fallback for JPEG or if fast paths fail + with Image.open(io.BytesIO(data)) as img: # Paste into a new image to strip metadata clean_img = Image.new(img.mode, img.size) clean_img.paste(img) diff --git a/ui/src/features/light-table/components/DownloadModal.tsx b/ui/src/features/light-table/components/DownloadModal.tsx index ebde870..2915cac 100644 --- a/ui/src/features/light-table/components/DownloadModal.tsx +++ b/ui/src/features/light-table/components/DownloadModal.tsx @@ -1,11 +1,47 @@ import { Download, X } from "lucide-react"; import type React from "react"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useEscapeToClose } from "../../../hooks/useEscapeToClose"; import { logger } from "../../../logger"; import * as imagesApi from "../../images/api/imagesApi"; +const STORAGE_KEY = "meld-download-options"; + +interface StoredDownloadOptions { + format: "zip" | "raw"; + removeMetadata: boolean; +} + +function loadStoredOptions(): StoredDownloadOptions { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved) as Partial; + const raw = parsed.removeMetadata as unknown; + // Legacy clients/saved states may store removeMetadata as boolean true, string "true", + // or numeric 1; preserve these exact values for backward compatibility. + const removeMetadata = + raw === true || raw === "true" || (typeof raw === "number" && raw === 1); + return { + format: parsed.format === "raw" ? "raw" : "zip", + removeMetadata, + }; + } + } catch (_e) { + // Ignore parse errors, use defaults + } + return { format: "zip", removeMetadata: false }; +} + +function saveStoredOptions(options: StoredDownloadOptions): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(options)); + } catch (_e) { + // Ignore quota errors + } +} + interface DownloadModalProps { imageIds: number[]; onSuccess?: () => void; @@ -13,12 +49,16 @@ interface DownloadModalProps { } export const DownloadModal: React.FC = ({ imageIds, onSuccess, onClose }) => { - const [format, setFormat] = useState<"zip" | "raw">("zip"); - const [removeMetadata, setRemoveMetadata] = useState(false); + const [options, setOptions] = useState(() => loadStoredOptions()); + const { format, removeMetadata } = options; const [isDownloading, setIsDownloading] = useState(false); const overlayMouseDownRef = useRef(false); + useEffect(() => { + saveStoredOptions(options); + }, [options]); + const handleOverlayMouseDown = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { overlayMouseDownRef.current = true; @@ -110,7 +150,7 @@ export const DownloadModal: React.FC = ({ imageIds, onSucces name="format" value="zip" checked={format === "zip"} - onChange={() => setFormat("zip")} + onChange={() => setOptions((o) => ({ ...o, format: "zip" }))} disabled={isDownloading} /> ZIP (Single File) @@ -128,7 +168,7 @@ export const DownloadModal: React.FC = ({ imageIds, onSucces name="format" value="raw" checked={format === "raw"} - onChange={() => setFormat("raw")} + onChange={() => setOptions((o) => ({ ...o, format: "raw" }))} disabled={isDownloading} /> Raw (Multiple Files) @@ -158,7 +198,7 @@ export const DownloadModal: React.FC = ({ imageIds, onSucces setRemoveMetadata(e.target.checked)} + onChange={(e) => setOptions((o) => ({ ...o, removeMetadata: e.target.checked }))} disabled={isDownloading} /> Remove metadata (ComfyUI workflow data) from downloaded images