Merge remote-tracking branch 'origin/main' into test/increase-coverage-to-50-percent
This commit is contained in:
@@ -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("<I", data[offset + 4 : offset + 8])[0]
|
||||
padding = chunk_size % 2
|
||||
chunk_full_size = 8 + chunk_size + padding
|
||||
if offset + chunk_full_size > 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("<I", vp8x_data[8:12])[0]
|
||||
flags &= ~8 # Unset EXIF
|
||||
flags &= ~4 # Unset XMP
|
||||
vp8x_data[8:12] = struct.pack("<I", flags)
|
||||
current_pos = out.tell()
|
||||
out.seek(vp8x_pos)
|
||||
out.write(vp8x_data)
|
||||
out.seek(current_pos)
|
||||
out.seek(4)
|
||||
out.write(struct.pack("<I", total_size - 8))
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _strip_metadata(image_path: str) -> 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)
|
||||
|
||||
@@ -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<StoredDownloadOptions>;
|
||||
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<DownloadModalProps> = ({ imageIds, onSuccess, onClose }) => {
|
||||
const [format, setFormat] = useState<"zip" | "raw">("zip");
|
||||
const [removeMetadata, setRemoveMetadata] = useState(false);
|
||||
const [options, setOptions] = useState<StoredDownloadOptions>(() => 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<DownloadModalProps> = ({ 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<DownloadModalProps> = ({ 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<DownloadModalProps> = ({ imageIds, onSucces
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeMetadata}
|
||||
onChange={(e) => setRemoveMetadata(e.target.checked)}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, removeMetadata: e.target.checked }))}
|
||||
disabled={isDownloading}
|
||||
/>
|
||||
Remove metadata (ComfyUI workflow data) from downloaded images
|
||||
|
||||
Reference in New Issue
Block a user